pranamanam commited on
Commit
ce385c8
·
verified ·
1 Parent(s): 2ac8892

Delete lecture_3

Browse files
lecture_3/README.md DELETED
@@ -1,139 +0,0 @@
1
- # Lecture 3: Score Matching and Diffusion Models
2
-
3
- Chapter 3 of the notes builds a generator out of corruption. A clean sample is pushed toward a
4
- standard Gaussian by a stochastic differential equation, Ito's lemma and the Fokker-Planck
5
- equation turn that equation into one for the density, and the variance-preserving choice of drift
6
- and noise scale makes the density Gaussian in closed form at every level. Reversing the motion
7
- needs the gradient of the log density, the score, which is learned by regression on the noise that
8
- was drawn. Discretizing the reverse equation on a grid gives DDPM; rewriting the Laplacian as a
9
- divergence gives the probability-flow ODE; and one extra gradient inside either sampler gives
10
- classifier, classifier-free and reward guidance. The last file runs the whole chain in the latent
11
- space of a protein language model.
12
-
13
- The clock runs from the data in every file here: `t = 0` and `k = 0` hold a clean sample and
14
- corruption grows with `t` and with `k`, up to a standard Gaussian at the terminal level. This is
15
- the reverse of Chapter 2's convention and it is kept throughout.
16
-
17
- | File | Command | What it does |
18
- | --- | --- | --- |
19
- | `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)`. |
20
- | `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)`. |
21
- | `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. |
22
- | `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. |
23
- | `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 the three are compared on equal evaluation budgets. |
24
- | `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. |
25
- | `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. |
26
-
27
- Every file takes the shared flags of `cis6270.runner.common_parser`. In `sde.py`,
28
- `forward_diffusion.py`, `samplers.py` and `guidance.py` nothing is trained, so `--steps` sets the
29
- number of simulation steps, measured levels or reverse steps; in the other three it sets the
30
- number of optimizer steps.
31
-
32
- ## What a default run prints
33
-
34
- `sde.py` is deterministic arithmetic plus a Monte Carlo check. The Euler-Maruyama step from
35
- `x = 2` lands at `2.11` with a drift displacement of `-0.04` against a noise displacement of
36
- `0.15`, and the typical noise is `2.5` times the drift at this step size. The driftless cloud ends
37
- with variance `0.2521` against the exact `g^2 T = 0.25`. The Ornstein-Uhlenbeck law at `t = log 2`
38
- has mean `1` and variance `0.75`, which `16384` simulated paths reproduce as `0.9860` and `0.7458`.
39
- Ito's lemma returns `E[W_t^2] = 0.2504` at `t = 0.25` against the exact `0.25`, and the same sum
40
- without the curvature term returns `0.0004`. The Fokker-Planck residual on `N(0, 1+t)` is zero to
41
- float precision, and the peak of the Gaussian falls from `0.3989` at the rate `-0.1995`, so the
42
- one-step estimate `0.3790` sits `0.0014` below the exact `0.3804`.
43
-
44
- `forward_diffusion.py` locates the four levels of the chapter's table at `t = 0.0589`, `0.2068`,
45
- `0.4544` and `0.7994`, with `sigma_t` equal to `0.199`, `0.600`, `0.9367` and `0.9992` and `beta(t)`
46
- equal to `1.2721`, `4.2157`, `9.1420` and `16.0073`; the two curves cross at `t = 0.25896`, where
47
- both are `0.7071`. On the thousand-step grid `abar_207 = 0.6402`, so `a_207 = 0.8001` against the
48
- continuous `0.7997` and `sigma_207 = 0.5999` against `0.6004`, and `a_1000 = 0.0064` against
49
- `0.0066`. One pixel at `x_0 = 0.5` and `eps = -1` lands at `-0.1998`, and by step `500` the signal
50
- is down to `0.2803`. The worked batch at `abar = 0.8` reproduces `(2.0125, -0.9839, 0.8944)`, a
51
- loss of `0.02`, score targets `(-1.118, 0.447, -2.236)` and clean estimates `(2.05, -1.05, 0.60)`.
52
- Corrupting one image `1024` times at each of `16` levels leaves the per-pixel sample mean within
53
- `0.0257` of `a_k x_0` and the pooled sample variance within `0.0026` of `sigma_k^2`, and both
54
- gaps fall as one over the square root of the number of draws.
55
-
56
- `score_matching.py` fits three networks from the same initialization on the same batches. The
57
- score errors against the exact field are `0.0337`, `0.1976` and `0.2073` for the explicit, implicit
58
- and denoising objectives: the explicit fit regresses on the exact target and comes out an order of
59
- magnitude closer, while the other two land near each other, each paying for the variance its own
60
- estimator adds. The ordering of those two moves with the seed. Along the family `a s_exact` the
61
- three minimizers come out `1.0000`, `0.9908` and `1.0053`, which is the claim that the three
62
- objectives agree on a minimizer, measured and not merely argued. The integration-by-parts identity
63
- closes to about one percent of its own size on a batch of 4096, the exact 64-dimensional trace
64
- takes 64 backward passes and returns `-64.00` against the Hutchinson estimate `-64.67` from 32
65
- passes, and the posterior at `x_t = 7.4` has weights `0.2156` and `0.7844`, clean mean `9.2156`,
66
- marginal score `-0.0765` by both routes and an irreducible residual of `0.835`.
67
-
68
- `ddpm.py` on the offline images drops `L_simple` from `0.169` over the first hundred steps to
69
- `0.033` over the last hundred, and the generated images land in `[-1.60, 1.50]` with a standard
70
- deviation of `0.59`, against training data in `[-1, 1]`. The reverse posterior variance at
71
- `k = 500` is `0.01003` and the ELBO weight it carries is `0.005508`, against `0.625` on the
72
- two-step hand schedule. That two-step transition gives `8.6461` from both forms of the mean, a
73
- clean estimate of `10` with interpolation weights `0.4969` and `0.4969`, variance `1/9`, and
74
- `x_1 = 8.746`.
75
-
76
- `samplers.py` gives all three samplers 200 score evaluations. Each finds all four modes with mode
77
- entropy `1.3854`, `1.3848` and `1.3852` against `log 4 = 1.386`, and the mean distance to the
78
- nearest mode is `0.4162` for ancestral DDPM, `0.4446` for the probability-flow ODE and `0.4662`
79
- for annealed Langevin, against `0.4435` for the target itself. The mean log densities are
80
- `-2.01`, `-2.15` and `-2.24` against the target's own `-2.15`. The worked backward step prints a
81
- score of `-1.6`, a deterministic velocity of `0.4` reaching `1.16`, and a reverse drift of `2.0`
82
- reaching `1.0` plus a Gaussian of standard deviation `0.4472`.
83
-
84
- `guidance.py` sweeps the weight on the mode at `(2, 2)`. The target fraction runs `0.256`, `0.334`,
85
- `0.506`, `0.899`, `1.000` at `w = 0, 0.1, 0.25, 0.5, 1`, and the scheduled version of `w = 1`
86
- reaches `0.991`, slightly weaker because `zeta(t) = 4t(1-t)` switches the condition off at both
87
- ends of the interval. Reward steering with `lambda = (1, 0)` moves the two right-hand modes from
88
- `0.491` of the mass unguided to `0.719` at `eta = 0.5` and `0.994` at `eta = 2`, and
89
- `lambda = (0.7, 0.3)` concentrates `0.740` on the top-right mode. The classifier route and the
90
- classifier-free route agree on the guided noise to float precision, which is the content of the
91
- rearrangement of Bayes' rule, and the guided backward step gives the velocity `-0.8` and the state
92
- `1.28` from the velocity form and the same `-0.8` from the guided noise `0.2`.
93
-
94
- `latent_diffusion.py` trains the transformer denoiser on 128 toy embeddings of 16 residues and 32
95
- coordinates. The masked loss falls from `1.038` to `0.817`. The two numbers that bracket it are
96
- `1.0`, which is what a denoiser that always returns zero scores on a standardized latent, and
97
- `0.725`, the posterior variance of `Z_0` given `Z_k` under a Gaussian latent, averaged over a
98
- uniform step index. Raising `--steps` to 2000 reaches `0.746`, close to that floor, for about
99
- three times the running time. Decoding the generated latents gives a polar fraction of `0.3047`, and the constraint
100
- raises it to exactly `0.50` by rewriting the cheapest positions at each sequence until eight are
101
- polar. The chapter's latent coordinate `z_0 = 1.2` corrupts to `0.7204` at `k = 500`, a prediction
102
- of `1.05` implies `eps = 0.4438`, and the reverse mean is `0.7193` with standard deviation
103
- `0.1002`.
104
-
105
- ## What is simplified
106
-
107
- - `ddpm.py` uses a U-Net of base width 8, where the notes uses 64, and trains for a few hundred
108
- steps at a learning rate of `2e-3` in place of 20 MNIST epochs at `2e-4`. Both are flags.
109
- The class, the schedule, the forward corruption and the reverse step are the chapter's code with
110
- its names kept.
111
- - `forward_diffusion.py` and `ddpm.py` are the two halves of the chapter's implementation section,
112
- split so that neither file runs long. No lecture file imports another, so each rebuilds the
113
- schedule tensors; the two copies are identical, and `tests/test_lecture_3.py` asserts that they
114
- stay so.
115
- - `samplers.py` and `guidance.py` evaluate the exact score of the corrupted four-Gaussian mixture
116
- in place of a trained network, which is what lets the three samplers and the guidance weights be
117
- compared with no training error in the way. A counter on the score stands in for the count of
118
- network evaluations. Both read the schedule off the continuous curve, `abar_k = exp(-B(t_k))`,
119
- and not from a linear sequence of betas, so that every `beta_k` stays below one on the coarse
120
- grid these files run; at `K = 1000` the two agree to about `1e-3`.
121
- - The conditional score in `guidance.py` is exact, so at `w = 1` the condition pins the mode and
122
- the mode fractions saturate. The graded part of the sweep therefore sits below one, where a
123
- trained conditional model would place it.
124
- - `score_matching.py` fits a time-independent field at the single level `a_t = 0.8`,
125
- `sigma_t = 0.6`, the chapter's running level, in place of one field across all levels.
126
- The Hutchinson estimator is written as a vector-Jacobian product; a score field has a symmetric
127
- Jacobian, so this equals the Jacobian-vector product of the notes at the same cost.
128
- - `latent_diffusion.py` runs offline on `cis6270.data.toy_embeddings`, which have 32 coordinates
129
- in place of the 1280 of ESM-2. Without the encoder there is no language-model head, so a fixed
130
- random projection stands in for it; the decoding path and the composition constraint run
131
- unchanged, and the residues they read out carry no biology. `--dataset esm2` uses the real
132
- encoder, the real head and the real tokenizer, at `MAX_LEN = 32` in place of the chapter's 64.
133
- That path downloads a 2.5 GB checkpoint and widens the denoiser to 1280, which is far too slow
134
- for the default `--steps` on a laptop CPU; try 20 first. Generation there fills all `MAX_LEN`
135
- positions, including the ones that held padding during training, exactly as the chapter's
136
- `sample_latents` does.
137
- - The reward model of Section 3.46 is a small network on the latent. In `guidance.py` the state is
138
- a point in the plane, so the two properties are read off its coordinates directly and keep the
139
- same signature, which leaves nothing to train.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_3/ddpm.py DELETED
@@ -1,315 +0,0 @@
1
- """DDPM, the reverse-time equation written on a finite grid.
2
-
3
- A U-Net reads a corrupted image and the step it sits at, and regresses the Gaussian draw that
4
- corrupted it. The reverse transition rebuilds the previous state from that one prediction, with a
5
- mean that comes from completing the square and the posterior variance as its spread, and the
6
- sampler starts at a standard Gaussian and applies that transition K times. The three pieces match
7
- the derivation one to one, with the chapter's names kept. The schedule and the forward corruption
8
- are the subject of `forward_diffusion.py`, rebuilt here so that this file stands alone.
9
-
10
- Chapter 3 runs its diffusion clock from the data, so step k = 0 holds a clean image and corruption
11
- grows with k, up to pure noise at k = K.
12
-
13
- Chapter 3, Sections 3.19, 3.20 and 3.24 through 3.28 of the notes. Trained on 28-by-28 images, the
14
- offline toy set by default and MNIST with --dataset mnist. Prints L_simple over the run, the
15
- reverse posterior variance and the ELBO weight it carries, the chapter's two-step reverse
16
- transition, and the range of the generated images.
17
-
18
- python lecture_3/ddpm.py # 300 steps on the offline images
19
- python lecture_3/ddpm.py --steps 20 # a few seconds, for a first look
20
- python lecture_3/ddpm.py --dataset mnist # the real digits, downloaded on first use
21
- """
22
-
23
- # %% 01. Imports and configuration
24
- import math
25
-
26
- import torch
27
- import torch.nn as nn
28
- import torch.nn.functional as F
29
-
30
- from cis6270.nets import count_parameters
31
- from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
32
-
33
- NUM_STEPS = 1000 # Diffusion steps K.
34
- BETA_START = 1e-4 # Noise variance added at step 1.
35
- BETA_END = 0.02 # Noise variance added at step K.
36
-
37
-
38
- # %% 02. The schedule tensors and the forward corruption
39
- #
40
- # alpha_k = 1 - beta_k is the one-step retention, abar_k its running product, and beta-tilde_k the
41
- # variance of the reverse posterior q(x_{k-1} | x_k, x_0). A zero is prefixed to the betas so that
42
- # Python index k holds beta_k and the cumulative product starts at abar_0 = 1, the clean state.
43
- # One Gaussian draw then reaches any level, x_k = a_k x_0 + sigma_k eps with a_k = sqrt(abar_k) and
44
- # sigma_k = sqrt(1 - abar_k).
45
-
46
- betas = torch.cat([
47
- torch.zeros(1), # Placeholder so that index k holds beta_k.
48
- torch.linspace(BETA_START, BETA_END, NUM_STEPS), # beta_1 ... beta_K.
49
- ])
50
- alphas = 1.0 - betas # One-step signal retention alpha_k.
51
- alphas_cumprod = torch.cumprod(alphas, dim=0) # Cumulative retention.
52
- alphas_cumprod_prev = torch.cat([
53
- torch.ones(1), # abar_0 = 1.
54
- alphas_cumprod[:-1], # Index k now holds abar_{k-1}.
55
- ])
56
- posterior_variance = (
57
- betas * (1.0 - alphas_cumprod_prev) / (1.0 - alphas_cumprod)
58
- ) # Reverse variance beta-tilde_k.
59
-
60
-
61
- def extract(schedule, k, shape):
62
- # One schedule value per item, reshaped to broadcast over C, H, W.
63
- out = schedule.to(k.device).gather(0, k)
64
- return out.reshape(k.shape[0], *((1,) * (len(shape) - 1)))
65
-
66
-
67
- def q_sample(x_0, k, noise=None):
68
- if noise is None:
69
- noise = torch.randn_like(x_0) # Mean 0, covariance I, shape of x_0.
70
- abar = extract(alphas_cumprod, k, x_0.shape)
71
- a_k = abar.sqrt() # Signal coefficient a_k.
72
- sigma_k = (1.0 - abar).sqrt() # Noise standard deviation sigma_k.
73
- return a_k * x_0 + sigma_k * noise, noise # x_k and the exact noise.
74
-
75
-
76
- # %% 03. Sinusoidal step embedding
77
- #
78
- # A raw index is a poor input to a linear layer, so the step is spread over many frequencies first.
79
-
80
-
81
- def timestep_embedding(k, dim):
82
- half = dim // 2 # Half the coordinates sine, half cosine.
83
- freqs = torch.exp(
84
- -math.log(10000.0)
85
- * torch.arange(half, device=k.device).to(torch.get_default_dtype()) / half
86
- )
87
- args = k.to(torch.get_default_dtype())[:, None] * freqs[None, :] # [B, half].
88
- return torch.cat([torch.sin(args), torch.cos(args)], dim=1) # [B, dim].
89
-
90
-
91
- def num_groups(channels):
92
- for g in (8, 4, 2, 1): # Largest valid GroupNorm group count.
93
- if channels % g == 0:
94
- return g
95
-
96
-
97
- # %% 04. The time-conditioned residual block
98
- #
99
- # The step embedding enters as a per-channel offset between the two convolutions, so every layer
100
- # operates with the current noise level available to it.
101
-
102
-
103
- class ResidualBlock(nn.Module):
104
- def __init__(self, in_channels, out_channels, time_embed_dim):
105
- super().__init__()
106
- self.norm1 = nn.GroupNorm(num_groups(in_channels), in_channels)
107
- self.conv1 = nn.Conv2d(in_channels, out_channels,
108
- kernel_size=3, padding=1)
109
- self.time_proj = nn.Linear(time_embed_dim, out_channels)
110
- self.norm2 = nn.GroupNorm(num_groups(out_channels), out_channels)
111
- self.conv2 = nn.Conv2d(out_channels, out_channels,
112
- kernel_size=3, padding=1)
113
- self.skip = (
114
- nn.Identity() if in_channels == out_channels
115
- else nn.Conv2d(in_channels, out_channels, kernel_size=1)
116
- )
117
- self.activation = nn.SiLU()
118
-
119
- def forward(self, x, t_emb):
120
- h = self.conv1(self.activation(self.norm1(x))) # GN, SiLU, Conv_1.
121
- h = h + self.time_proj(t_emb)[:, :, None, None] # Offset per channel.
122
- h = self.conv2(self.activation(self.norm2(h)))
123
- return h + self.skip(x) # Residual connection.
124
-
125
-
126
- # %% 05. The U-Net
127
- #
128
- # Three scales, 28 by 28 down to 7 by 7 and back, with a skip at each resolution. The output has
129
- # the shape of the input because it predicts one noise coordinate per pixel.
130
-
131
-
132
- class UNet(nn.Module):
133
- def __init__(self, in_channels=1, base_channels=64, time_embed_dim=128):
134
- super().__init__()
135
- self.time_embed_dim = time_embed_dim
136
- self.time_mlp = nn.Sequential( # Learned representation of step k.
137
- nn.Linear(time_embed_dim, time_embed_dim),
138
- nn.SiLU(),
139
- nn.Linear(time_embed_dim, time_embed_dim),
140
- )
141
- c1 = base_channels # 64 channels at 28 x 28.
142
- c2 = base_channels * 2 # 128 channels at 14 x 14.
143
- c3 = base_channels * 4 # 256 channels at 7 x 7.
144
-
145
- self.in_conv = nn.Conv2d(in_channels, c1, kernel_size=3, padding=1)
146
- self.down1 = ResidualBlock(c1, c1, time_embed_dim)
147
- self.pool1 = nn.Conv2d(c1, c1, kernel_size=3, stride=2, padding=1)
148
- self.down2 = ResidualBlock(c1, c2, time_embed_dim)
149
- self.pool2 = nn.Conv2d(c2, c2, kernel_size=3, stride=2, padding=1)
150
- self.mid = ResidualBlock(c2, c3, time_embed_dim)
151
- self.up1 = nn.ConvTranspose2d(c3, c2, kernel_size=4,
152
- stride=2, padding=1)
153
- self.dec1 = ResidualBlock(c2 + c2, c2, time_embed_dim)
154
- self.up2 = nn.ConvTranspose2d(c2, c1, kernel_size=4,
155
- stride=2, padding=1)
156
- self.dec2 = ResidualBlock(c1 + c1, c1, time_embed_dim)
157
- self.out_conv = nn.Conv2d(c1, in_channels, kernel_size=1)
158
-
159
- def forward(self, x, k):
160
- t_emb = self.time_mlp(
161
- timestep_embedding(k, self.time_embed_dim)
162
- ) # Learned representation of diffusion step k.
163
- h = self.in_conv(x)
164
- h1 = self.down1(h, t_emb) # 28 x 28 encoder features.
165
- h = self.pool1(h1) # 28 -> 14.
166
- h2 = self.down2(h, t_emb) # 14 x 14 encoder features.
167
- h = self.pool2(h2) # 14 -> 7.
168
- h = self.mid(h, t_emb) # Compressed representation.
169
- h = self.up1(h) # 7 -> 14.
170
- h = self.dec1(torch.cat([h, h2], dim=1), t_emb) # Join 14 x 14 skip.
171
- h = self.up2(h) # 14 -> 28.
172
- h = self.dec2(torch.cat([h, h1], dim=1), t_emb) # Join 28 x 28 skip.
173
- return self.out_conv(h) # [B, 1, 28, 28] predicted noise.
174
-
175
-
176
- # %% 06. One reverse step and the sampler
177
-
178
-
179
- @torch.no_grad()
180
- def p_sample(model, x_k, k):
181
- eps_theta = model(x_k, k) # Predicted noise at step k.
182
- beta_k = extract(betas, k, x_k.shape)
183
- alpha_k = extract(alphas, k, x_k.shape)
184
- abar_k = extract(alphas_cumprod, k, x_k.shape)
185
- mean = (
186
- x_k - beta_k / (1.0 - abar_k).sqrt() * eps_theta
187
- ) / alpha_k.sqrt() # Reverse mean mu_theta.
188
- var = extract(posterior_variance, k, x_k.shape) # beta-tilde_k.
189
- z = torch.randn_like(x_k) # Fresh Gaussian draw.
190
- keep = (k > 1).to(torch.get_default_dtype()).reshape(-1, *((1,) * (x_k.dim() - 1)))
191
- return mean + keep * var.sqrt() * z # No noise at the final step.
192
-
193
-
194
- @torch.no_grad()
195
- def sample(model, num_images, device):
196
- x = torch.randn(num_images, 1, 28, 28, device=device) # X_K ~ N(0, I).
197
- for step in range(NUM_STEPS, 0, -1): # k = K down to k = 1.
198
- k = torch.full((num_images,), step, device=device, dtype=torch.long)
199
- x = p_sample(model, x, k)
200
- return x # Generated images in the training range [-1, 1].
201
-
202
-
203
- # %% 07. The dropped weight and one complete reverse transition
204
- #
205
- # One interior term of the variational bound is beta_k^2 / (2 beta-tilde_k alpha_k (1 - abar_k))
206
- # multiplied by the squared noise error, and L_simple sets every such factor to one. On the
207
- # two-step schedule beta_1 = beta_2 = 0.2 the whole transition is small enough to check by hand,
208
- # and the mean there has two equal forms, one built from the noise prediction and one from the
209
- # clean estimate it implies.
210
-
211
-
212
- def elbo_weight(beta_k, posterior, alpha_k, abar_k) -> float:
213
- """The schedule constant in front of the squared noise error at one step."""
214
- return beta_k ** 2 / (2.0 * posterior * alpha_k * (1.0 - abar_k))
215
-
216
-
217
- def two_step_transition(beta: float, x_k: float, eps_theta: float, z: float) -> dict:
218
- """The reverse step at k = 2 of the two-step schedule, through both forms of the mean."""
219
- alpha = 1.0 - beta # alpha_2.
220
- abar_1, abar_2 = alpha, alpha ** 2 # 0.8 and 0.64 at beta = 0.2.
221
- sigma = math.sqrt(1.0 - abar_2) # 0.6, the corruption standard deviation at k = 2.
222
- mean = (x_k - beta / sigma * eps_theta) / math.sqrt(alpha) # The noise form of mu_theta.
223
- x_0_hat = (x_k - sigma * eps_theta) / math.sqrt(abar_2) # The clean estimate it implies.
224
- weights = (math.sqrt(abar_1) * beta / (1.0 - abar_2), # Weight on the clean estimate.
225
- math.sqrt(alpha) * (1.0 - abar_1) / (1.0 - abar_2)) # Weight on x_k.
226
- variance = beta * (1.0 - abar_1) / (1.0 - abar_2) # beta-tilde_2 = 1/9.
227
- return {
228
- "mean_from_noise": mean, "mean_from_clean": weights[0] * x_0_hat + weights[1] * x_k,
229
- "x_0_hat": x_0_hat, "weights": weights, "variance": variance,
230
- "x_{k-1}": mean + math.sqrt(variance) * z,
231
- "elbo_weight": elbo_weight(beta, variance, alpha, abar_2),
232
- }
233
-
234
-
235
- # %% 08. The report
236
- def main() -> None:
237
- parser = common_parser(__doc__.split("\n")[0])
238
- parser.add_argument("--dataset", choices=["toy", "mnist"], default="toy")
239
- parser.add_argument("--base-channels", type=int, default=8,
240
- help="U-Net base width; the notes uses 64")
241
- parser.set_defaults(steps=300, batch_size=64, lr=2e-3, samples=4)
242
- args = parser.parse_args()
243
- set_seed(args.seed)
244
- device = pick_device(args.device)
245
-
246
- if args.dataset == "toy":
247
- from cis6270.data import toy_images
248
-
249
- images = toy_images(512, 28, args.seed).to(device) # [512, 1, 28, 28] offline images.
250
- draw = lambda n: images[torch.randint(0, len(images), (n,), device=device)] # noqa: E731
251
- else:
252
- from cis6270.data import mnist_loader
253
-
254
- loader = mnist_loader(batch_size=args.batch_size)
255
- stream = iter(loader)
256
-
257
- def draw(n): # One MNIST batch per call, restarting the loader at the end of an epoch.
258
- nonlocal stream
259
- batch = next(stream, None)
260
- if batch is None:
261
- stream = iter(loader)
262
- batch = next(stream)
263
- return batch[0].to(device)
264
-
265
- model = UNet(in_channels=1, base_channels=args.base_channels, time_embed_dim=128).to(device)
266
- if not args.quiet:
267
- print(f"device={device} dataset={args.dataset} parameters={count_parameters(model):,}")
268
-
269
- def loss_fn(step: int) -> torch.Tensor:
270
- x_0 = draw(args.batch_size) # [B, 1, 28, 28] clean images in [-1, 1].
271
- k = torch.randint(1, NUM_STEPS + 1, (x_0.shape[0],), device=device) # One level per item.
272
- x_k, noise = q_sample(x_0, k) # Corrupt directly to step k.
273
- return F.mse_loss(model(x_k, k), noise) # L_simple on this batch.
274
-
275
- # The notes trains at 2e-4 for 20 epochs; 2e-3 for a few hundred steps is the
276
- # classroom version of the same run and reaches a usable sampler in under a minute.
277
- losses = train(model, loss_fn, args.steps, args.lr, quiet=args.quiet)
278
- images_out = sample(model, args.samples, device) # [S, 1, 28, 28] after K reverse steps.
279
-
280
- window = min(100, args.steps) # A short run averages over everything it has.
281
- reverse = two_step_transition(0.2, 7.4, -1.0, 0.3)
282
- weight_500 = elbo_weight(betas[500], posterior_variance[500],
283
- alphas[500], alphas_cumprod[500]) # The dropped factor at k = 500.
284
- report = {
285
- "parameters": count_parameters(model),
286
- f"loss, first {window} steps": sum(losses[:window]) / window,
287
- f"loss, last {window} steps": sum(losses[-window:]) / window,
288
- "beta-tilde at k=500": f"{float(posterior_variance[500]):.5f}", # 0.01003.
289
- # The weight L_simple drops, on the thousand-step grid and on the two-step hand schedule.
290
- "ELBO weight, k=500 and two-step": [round(float(weight_500), 6),
291
- round(reverse["elbo_weight"], 4)], # 0.005508, 0.625.
292
- "two-step reverse mean, two forms": [round(reverse["mean_from_noise"], 4),
293
- round(reverse["mean_from_clean"], 4)], # 8.646.
294
- "two-step x_0 estimate and weights":
295
- [round(reverse["x_0_hat"], 4)] + [round(w, 4) for w in reverse["weights"]],
296
- "two-step reverse variance and x_1": [round(reverse["variance"], 4),
297
- round(reverse["x_{k-1}"], 4)], # 1/9 and 8.746.
298
- "generated range": [round(float(images_out.min()), 3), round(float(images_out.max()), 3)],
299
- "generated standard deviation": float(images_out.std()),
300
- }
301
- print_report(f"DDPM on {args.dataset} images", report)
302
- saved = save_run(args.out, model=model, config=vars(args), report=report, losses=losses)
303
- if saved is not None:
304
- try: # Without torchvision the run still trains, samples and saves its weights.
305
- from torchvision.utils import save_image
306
-
307
- display = ((images_out.cpu() + 1.0) / 2.0).clamp(0.0, 1.0) # [-1, 1] -> [0, 1].
308
- save_image(display, saved / "samples.png", nrow=4) # A grid of the generated images.
309
- except ImportError:
310
- pass
311
-
312
-
313
- # %% 09. Run the script
314
- if __name__ == "__main__":
315
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_3/forward_diffusion.py DELETED
@@ -1,204 +0,0 @@
1
- """The schedule and the Gaussian corruption it drives.
2
-
3
- The variance-preserving path is fixed once a noising rate is chosen. Its accumulated rate gives a
4
- signal coefficient and a noise standard deviation at every level, a thousand-step grid tracks the
5
- continuous curve to three decimal places, and one Gaussian draw is enough to reach any level from
6
- a clean image. Nothing here learns anything, so every number is arithmetic or a sample average
7
- against a law written in closed form.
8
-
9
- Chapter 3 runs its diffusion clock from the data, so step k = 0 holds a clean image and corruption
10
- grows with k, up to pure noise at k = K.
11
-
12
- Chapter 3, Sections 3.11 and 3.12 of the notes. Runs on the offline 28-by-28 images. Prints the
13
- four noise levels of the schedule, the discrete grid against the continuous curve, the chapter's
14
- one pixel at step 207, the worked training batch at abar = 0.8, and the corruption law measured on
15
- a batch.
16
-
17
- python lecture_3/forward_diffusion.py # 16 levels, 1024 draws at each
18
- python lecture_3/forward_diffusion.py --steps 20 # a few seconds, for a first look
19
- """
20
-
21
- # %% 01. Imports and configuration
22
- import math
23
-
24
- import torch
25
-
26
- from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed
27
-
28
- NUM_STEPS = 1000 # Diffusion steps K.
29
- BETA_START = 1e-4 # Noise variance added at step 1.
30
- BETA_END = 0.02 # Noise variance added at step K.
31
- BETA_MIN = 0.1 # beta(0) of the continuous schedule these endpoints discretize.
32
- BETA_MAX = 20.0 # beta(1) of the same schedule.
33
-
34
- # `--steps` is the number of levels at which the corruption law is measured and `--samples` the
35
- # number of draws taken at each of them; nothing in this file is trained.
36
-
37
-
38
- # %% 02. The schedule tensors
39
- #
40
- # alpha_k = 1 - beta_k is the one-step retention and abar_k its running product. A zero is prefixed
41
- # to the betas so that Python index k holds beta_k and the cumulative product starts at abar_0 = 1,
42
- # the clean state. The two corruption coefficients are the square roots of abar_k and 1 - abar_k.
43
-
44
- betas = torch.cat([
45
- torch.zeros(1), # Placeholder so that index k holds beta_k.
46
- torch.linspace(BETA_START, BETA_END, NUM_STEPS), # beta_1 ... beta_K.
47
- ])
48
- alphas = 1.0 - betas # One-step signal retention alpha_k.
49
- alphas_cumprod = torch.cumprod(alphas, dim=0) # Cumulative retention abar_k.
50
- a = alphas_cumprod.sqrt() # Signal coefficient a_k.
51
- sigma = (1.0 - alphas_cumprod).sqrt() # Noise standard deviation sigma_k.
52
-
53
-
54
- # %% 03. Forward corruption
55
- #
56
- # x_k = a_k x_0 + sigma_k eps, the Gaussian transition q(x_k | x_0) = N(a_k x_0, sigma_k^2 I) read
57
- # off at step k. One draw reaches any level, so training never simulates the path.
58
-
59
-
60
- def extract(schedule, k, shape):
61
- # One schedule value per item, reshaped to broadcast over C, H, W.
62
- out = schedule.to(k.device).gather(0, k)
63
- return out.reshape(k.shape[0], *((1,) * (len(shape) - 1)))
64
-
65
-
66
- def q_sample(x_0, k, noise=None):
67
- if noise is None:
68
- noise = torch.randn_like(x_0) # Mean 0, covariance I, shape of x_0.
69
- abar = extract(alphas_cumprod, k, x_0.shape)
70
- a_k = abar.sqrt() # Signal coefficient a_k.
71
- sigma_k = (1.0 - abar).sqrt() # Noise standard deviation sigma_k.
72
- return a_k * x_0 + sigma_k * noise, noise # x_k and the exact noise.
73
-
74
-
75
- # %% 04. The continuous schedule behind the grid
76
- #
77
- # beta(t) = 0.1 + 19.9 t integrates to B(t) = 0.1 t + 9.95 t^2 and a_t = exp(-B(t)/2). Dividing the
78
- # per-step variances 1e-4 and 0.02 by the step size 1/1000 recovers the two endpoints, so the grid
79
- # and the curve describe one path. Inverting a_t = exp(-B(t)/2) locates a chosen noise level.
80
-
81
-
82
- def noising_rate(t: float) -> float:
83
- """beta(t) = beta_min + (beta_max - beta_min) t, the linear noising rate."""
84
- return BETA_MIN + (BETA_MAX - BETA_MIN) * t
85
-
86
-
87
- def continuous_signal(t: float) -> float:
88
- """a_t = exp(-B(t)/2) with B(t) = 0.1 t + 9.95 t^2, the curve the discrete betas track."""
89
- return math.exp(-(BETA_MIN * t + 0.5 * (BETA_MAX - BETA_MIN) * t ** 2) / 2.0)
90
-
91
-
92
- def level_time(a_t: float) -> dict:
93
- """Solve B(t) = -2 log a_t for the time at which the signal coefficient is a_t.
94
-
95
- The quadratic 9.95 t^2 + 0.1 t - B = 0 has one positive root, which is the time the notes
96
- tabulates beside the noise standard deviation and the noising rate there.
97
- """
98
- accumulated = -2.0 * math.log(a_t) # B(t) at the requested level.
99
- quadratic = 0.5 * (BETA_MAX - BETA_MIN) # Coefficient of t^2 in B(t).
100
- t = (-BETA_MIN + math.sqrt(BETA_MIN ** 2 + 4.0 * quadratic * accumulated)) / (2.0 * quadratic)
101
- return {"t": t, "sigma_t": math.sqrt(1.0 - a_t ** 2), "beta_t": noising_rate(t)}
102
-
103
-
104
- # %% 05. One training batch at the running level
105
- #
106
- # Three clean scalars at a level with abar = 0.8 give the batch the notes carries through the
107
- # denoising loss by hand: the corrupted values, the squared error against a supposed prediction,
108
- # the score targets the noise regression never forms, and the clean estimates the prediction
109
- # implies.
110
-
111
-
112
- def worked_batch(abar: float, x_0, epsilon, predicted) -> dict:
113
- """One training batch at a level with the given cumulative retention, as the notes works it."""
114
- a_k, sigma_k = math.sqrt(abar), math.sqrt(1.0 - abar) # 0.8944 and 0.4472 at abar = 0.8.
115
- x_0, epsilon, predicted = (torch.tensor(v) for v in (x_0, epsilon, predicted))
116
- x_k = a_k * x_0 + sigma_k * epsilon # [3] the corrupted batch.
117
- return {
118
- "a_k": a_k, "sigma_k": sigma_k,
119
- "x_k": [round(float(v), 4) for v in x_k],
120
- "loss": float((predicted - epsilon).square().mean()), # L_simple on this batch.
121
- "score_targets": [round(float(-e / sigma_k), 3) for e in epsilon],
122
- "x_0_hat": [round(float((x - sigma_k * p) / a_k), 2) for x, p in zip(x_k, predicted)],
123
- }
124
-
125
-
126
- # %% 06. The corruption law, measured
127
- #
128
- # q(x_k | x_0) is Gaussian with mean a_k x_0 and variance sigma_k^2 in every coordinate. Corrupting
129
- # one clean image many times at a fixed level and averaging over the draws therefore recovers
130
- # a_k x_0 pixel by pixel, and the spread around it recovers sigma_k^2. The largest gap over a
131
- # spread of levels falls as one over the square root of the number of draws, and a wrong
132
- # coefficient in either of the two lines of `q_sample` would leave a gap that does not.
133
-
134
-
135
- def measure_law(x_0, levels, repeats: int, generator=None) -> dict:
136
- """Corrupt one clean image ``repeats`` times at each level and compare with q(x_k | x_0)."""
137
- batch = x_0.expand(repeats, *x_0.shape[1:]) # [R, 1, 28, 28] one image, repeated.
138
- mean_gap, variance_gap = 0.0, 0.0
139
- for step in levels:
140
- k = torch.full((repeats,), int(step), dtype=torch.long) # [R] the same level throughout.
141
- noise = torch.randn(batch.shape, generator=generator) # [R, 1, 28, 28] fresh draws.
142
- x_k, _ = q_sample(batch, k, noise) # [R, 1, 28, 28] corrupted at this level.
143
- centre = float(a[step]) * x_0[0] # [1, 28, 28] the conditional mean a_k x_0.
144
- mean_gap = max(mean_gap, float((x_k.mean(dim=0) - centre).abs().mean()))
145
- spread = float(x_k.var(dim=0, unbiased=False).mean()) # Pooled over the pixels.
146
- variance_gap = max(variance_gap, abs(spread - float(sigma[step]) ** 2))
147
- return {"mean_gap": mean_gap, "variance_gap": variance_gap}
148
-
149
-
150
- # %% 07. The report
151
- def main() -> None:
152
- parser = common_parser(__doc__.split("\n")[0])
153
- parser.set_defaults(steps=16, samples=1024)
154
- args = parser.parse_args()
155
- set_seed(args.seed)
156
- device = pick_device(args.device) # Printed for consistency; this file runs on the CPU.
157
- generator = torch.Generator().manual_seed(args.seed) # Local generator, so the report repeats.
158
-
159
- from cis6270.data import toy_images
160
-
161
- image = toy_images(1, 28, args.seed) # [1, 1, 28, 28] one offline image in [-1, 1].
162
- levels = torch.linspace(1, NUM_STEPS, args.steps).round().long().tolist() # Spread over K.
163
- law = measure_law(image, levels, args.samples, generator)
164
-
165
- # The four levels of the notes' table, located by inverting a_t = exp(-B(t)/2).
166
- located = [level_time(level) for level in (0.98, 0.8, 0.35, 0.04)]
167
- crossing = level_time(1.0 / math.sqrt(2.0)) # Where a_t and sigma_t meet.
168
- batch = worked_batch(0.8, (2.0, -1.0, 0.5), (0.5, -0.2, 1.0), (0.4, -0.1, 0.8))
169
-
170
- report = {
171
- "levels measured, draws each": [len(levels), args.samples],
172
- "largest gap, sample mean": law["mean_gap"], # Zero up to the sampling error.
173
- "largest gap, sample variance": law["variance_gap"],
174
- "times at a_t = 0.98, 0.8, 0.35, 0.04": [round(v["t"], 4) for v in located],
175
- "sigma_t at the same four levels": [round(v["sigma_t"], 4) for v in located],
176
- "beta(t) at the same four levels": [round(v["beta_t"], 4) for v in located],
177
- # Where the two curves cross, and the value they share there.
178
- "crossing time and a_t = sigma_t": [round(crossing["t"], 5),
179
- round(crossing["sigma_t"], 4)], # 0.25896, 0.7071.
180
- # The discrete coefficient against the continuous curve, at step 207 and at the last step.
181
- "abar_207": round(float(alphas_cumprod[207]), 4), # 0.6402.
182
- "a_207 discrete and continuous": [round(float(a[207]), 4),
183
- round(continuous_signal(0.207), 4)], # 0.8001, 0.7997.
184
- "sigma_207 discrete and continuous": [round(float(sigma[207]), 4),
185
- round(math.sqrt(1.0 - continuous_signal(0.207) ** 2),
186
- 4)], # 0.5999, 0.6004.
187
- "a_1000 discrete and continuous": [round(float(a[1000]), 4),
188
- round(continuous_signal(1.0), 4)], # 0.0064, 0.0066.
189
- "one pixel at k=207, x_0=0.5, eps=-1": float(a[207] * 0.5 - sigma[207]), # -0.1998.
190
- "a_500, where noise exceeds signal": round(float(a[500]), 4), # 0.2803.
191
- "worked batch at abar=0.8, x_k": batch["x_k"], # 2.0125, -0.9839, 0.8944.
192
- "worked batch loss": batch["loss"], # 0.02.
193
- "worked batch score targets": batch["score_targets"], # -1.118, 0.447, -2.236.
194
- "worked batch x_0 estimates": batch["x_0_hat"], # 2.05, -1.05, 0.60.
195
- }
196
- if not args.quiet:
197
- print(f"device={device} levels={args.steps} draws={args.samples} seed={args.seed}")
198
- print_report("The variance-preserving corruption", report)
199
- save_run(args.out, config=vars(args), report=report)
200
-
201
-
202
- # %% 08. Run the script
203
- if __name__ == "__main__":
204
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_3/guidance.py DELETED
@@ -1,280 +0,0 @@
1
- """Steering a sampler by adding one gradient to the score.
2
-
3
- Bayes' rule splits the conditional score into the unconditional score plus the gradient of the log
4
- likelihood of the condition, so every form of control in this chapter is one extra term in the
5
- same place. A classifier supplies that term directly, a conditional and an unconditional
6
- prediction of one network supply it as their difference, and a differentiable reward supplies it
7
- as its own gradient, with several objectives combined through weights on the simplex. The target
8
- is the four-Gaussian mixture with one label per mode, where every score is a closed form, so the
9
- guidance weight shows up directly in the mode fractions.
10
-
11
- Chapter 3 runs its diffusion clock from the data, so t = 0 holds a clean sample and corruption
12
- grows with t. Generation traverses the same interval from t = 1 back to t = 0.
13
-
14
- Chapter 3, Sections 3.37 through 3.45 of the notes. Prints the mode fractions at several guidance
15
- weights, the chapter's worked guided score and guided backward step, the two candidates under two
16
- weightings, and the schedule value at t = 0.6.
17
-
18
- python lecture_3/guidance.py # 200 reverse steps per setting
19
- python lecture_3/guidance.py --steps 20 # a coarse grid, for a first look
20
- """
21
-
22
- # %% 01. Imports and configuration
23
- import math
24
-
25
- import torch
26
-
27
- from cis6270.data import GAUSSIAN_CENTERS, GAUSSIAN_STD
28
- from cis6270.metrics import mode_report
29
- from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed
30
-
31
- BETA_MIN = 0.1 # beta(0) of the linear variance-preserving schedule.
32
- BETA_MAX = 20.0 # beta(1) of the same schedule.
33
- TARGET_LABEL = 0 # The mode at (2, 2), which every guided run is steered toward.
34
- REWARD_SCALE = 2.0 # Distance from the origin to a mode, used to standardize the two rewards.
35
-
36
-
37
- # %% 02. The target, its labels and the exact scores
38
- #
39
- # Each mode carries its own label, so p_t(x | c) is a single Gaussian with mean a_t mu_c and
40
- # variance a_t^2 s^2 + sigma_t^2, and p_t(x) is the equal mixture of the four. Both scores are
41
- # closed forms, and nothing in this file is trained.
42
-
43
-
44
- def schedule_tensors(num_steps: int) -> dict:
45
- """The DDPM tensors on a grid of K steps, read off abar_k = exp(-B(t_k))."""
46
- grid = (torch.arange(0, num_steps + 1).to(torch.get_default_dtype())
47
- / num_steps) # [K+1] the times t_k.
48
- alphas_cumprod = torch.exp(-(BETA_MIN * grid + 0.5 * (BETA_MAX - BETA_MIN) * grid ** 2))
49
- previous = torch.cat([torch.ones(1), alphas_cumprod[:-1]]) # abar_{k-1}.
50
- alphas = alphas_cumprod / previous # One-step retention alpha_k.
51
- betas = 1.0 - alphas # Per-step variance beta_k.
52
- return {"betas": betas, "alphas": alphas, "alphas_cumprod": alphas_cumprod,
53
- "posterior_variance": betas * (1.0 - previous) / (1.0 - alphas_cumprod)}
54
-
55
-
56
- def component_log_weights(x: torch.Tensor, a_t: float):
57
- """Log of each mode's unnormalized contribution at x, and the offsets to the four means."""
58
- variance = a_t ** 2 * GAUSSIAN_STD ** 2 + (1.0 - a_t ** 2) # Width of p_t.
59
- offset = x[:, None, :] - a_t * GAUSSIAN_CENTERS.to(x)[None, :, :] # [B, 4, 2].
60
- return -(offset ** 2).sum(dim=2) / (2.0 * variance), offset, variance
61
-
62
-
63
- def unconditional_score(x: torch.Tensor, a_t: float) -> torch.Tensor:
64
- """The score of the equal mixture at level a_t. [B, 2] -> [B, 2]."""
65
- log_weight, offset, variance = component_log_weights(x, a_t)
66
- responsibility = log_weight.softmax(dim=1)[:, :, None] # [B, 4, 1] posterior over the modes.
67
- return (responsibility * (-offset / variance)).sum(dim=1) # [B, 2].
68
-
69
-
70
- def conditional_score(x: torch.Tensor, a_t: float, label: int) -> torch.Tensor:
71
- """The score of p_t(. | c), a single Gaussian centered on a_t mu_c. [B, 2] -> [B, 2]."""
72
- _, offset, variance = component_log_weights(x, a_t)
73
- return -offset[:, label, :] / variance # [B, 2].
74
-
75
-
76
- # %% 03. Conditioning the score and the guidance scale
77
- #
78
- # s_t(x | c) = s_t(x) + grad log p_t(c | x), and attaching a scale gamma to the second term samples
79
- # the tilted density p_t(x) p_t(c | x)^gamma. At gamma = 0 generation is unconditional, at
80
- # gamma = 1 it follows Bayes' rule, and at gamma > 1 the condition is weighted more heavily.
81
-
82
-
83
- def classifier_gradient(x: torch.Tensor, a_t: float, label: int) -> torch.Tensor:
84
- """grad log p_t(c | x), taken as the difference of the two scores. [B, 2] -> [B, 2]."""
85
- return conditional_score(x, a_t, label) - unconditional_score(x, a_t)
86
-
87
-
88
- def guided_score(score: torch.Tensor, gradient: torch.Tensor, gamma: float) -> torch.Tensor:
89
- """s_theta + gamma grad log p_phi(c | x), the guided score of the notes."""
90
- return score + gamma * gradient
91
-
92
-
93
- def guided_noise(eps: torch.Tensor, gradient: torch.Tensor, gamma: float, sigma_k: float):
94
- """eps_theta - gamma sigma_t grad log p_phi(c | x), the same guidance in the noise."""
95
- return eps - gamma * sigma_k * gradient
96
-
97
-
98
- def classifier_free_field(unconditional, conditional, w: float):
99
- """F_uncond + w (F_cond - F_uncond), which needs no separate classifier."""
100
- return unconditional + w * (conditional - unconditional)
101
-
102
-
103
- # %% 04. Reward guidance and several objectives at once
104
- #
105
- # A reward is a differentiable scalar that is larger for more desirable states, and exponential
106
- # reweighting turns it into a density whose score is s_t(x) + eta grad R_t(x). With M objectives,
107
- # the weights are clamped to be nonnegative and divided by their sum, which is the simplex
108
- # condition of the notes, and the gradient is linear in them.
109
-
110
-
111
- def reward_model(z: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
112
- """Two standardized properties of the state, its reach to the right and its reach upward.
113
-
114
- The notes train a small network on the latent and the time here. Two coordinates of a planar
115
- state need no network, so the properties are read off directly and keep the same signature.
116
- """
117
- return z / REWARD_SCALE # [B, 2] one standardized property per coordinate.
118
-
119
-
120
- def normalize_weights(lambdas, device):
121
- """Clamp the weights to be nonnegative and divide by their sum, the simplex condition."""
122
- w = torch.as_tensor(lambdas, dtype=torch.get_default_dtype(), device=device)
123
- w = w.clamp_min(0.0)
124
- return w / w.sum().clamp_min(1e-8)
125
-
126
-
127
- def weighted_reward(reward_model, z, t, lambdas):
128
- """R_lambda(z) = sum_m lambda_m r_m(z), one scalar per batch item."""
129
- r_hat = reward_model(z, t) # [B, M]
130
- return (r_hat * normalize_weights(lambdas, z.device)).sum(dim=1)
131
-
132
-
133
- def reward_gradient(reward_model, z, t, lambdas):
134
- """grad_z R_lambda(z), taken inside a no-grad sampler. [B, d] -> [B, d]."""
135
- with torch.enable_grad():
136
- state = z.detach().requires_grad_(True)
137
- R = weighted_reward(reward_model, state, t, lambdas).sum()
138
- grad, = torch.autograd.grad(R, state)
139
- return grad.detach()
140
-
141
-
142
- def scalarize(values, lambdas) -> float:
143
- """The weighted sum of normalized objectives, R_lambda(x), on one candidate."""
144
- weights = normalize_weights(lambdas, torch.device("cpu"))
145
- return float((torch.as_tensor(values, dtype=torch.get_default_dtype()) * weights).sum())
146
-
147
-
148
- # %% 05. Scheduled guidance
149
- #
150
- # A property predictor is not equally reliable at every noise level, so the strength is allowed to
151
- # depend on time, gamma(t) = gamma_max zeta(t). The schedule zeta(t) = 4 t (1 - t) vanishes at both
152
- # ends of the interval and peaks at one in the middle.
153
-
154
-
155
- def schedule_value(t: float) -> float:
156
- """zeta(t) = 4 t (1 - t), between zero and one."""
157
- return 4.0 * t * (1.0 - t)
158
-
159
-
160
- # %% 06. The guided sampler
161
- #
162
- # The reverse step is the one of Section 3.26 with the guided noise prediction in place of the
163
- # raw one. Classifier-free guidance is applied first, then the reward term, which is the order the
164
- # notes implements.
165
-
166
-
167
- @torch.no_grad()
168
- def sample_diffusion(schedule, num_points, label, w=0.0, eta=0.0, lambdas=(1.0, 0.0),
169
- scheduled=False, generator=None) -> torch.Tensor:
170
- """Draw points under classifier-free guidance toward ``label`` and reward steering."""
171
- num_steps = len(schedule["betas"]) - 1
172
- z = torch.randn(num_points, 2, generator=generator) # [N, 2] the terminal Gaussian.
173
- for step in range(num_steps, 0, -1): # k = K down to k = 1.
174
- t = step / num_steps # Normalized time, the input the network would receive.
175
- abar = float(schedule["alphas_cumprod"][step])
176
- a_k, sigma_k = math.sqrt(abar), math.sqrt(1.0 - abar)
177
- eps = -sigma_k * unconditional_score(z, a_k) # [N, 2] unconditional noise.
178
- if w > 0.0:
179
- conditional = -sigma_k * conditional_score(z, a_k, label) # [N, 2].
180
- strength = w * (schedule_value(t) if scheduled else 1.0) # gamma(t) = gamma_max zeta.
181
- eps = classifier_free_field(eps, conditional, strength)
182
- if eta > 0.0:
183
- grad_R = reward_gradient(reward_model, z, torch.full((num_points,), t), lambdas)
184
- eps = eps - eta * sigma_k * grad_R # Reward guidance in the noise parameterization.
185
- beta_k, alpha_k = float(schedule["betas"][step]), float(schedule["alphas"][step])
186
- mean = (z - beta_k / sigma_k * eps) / math.sqrt(alpha_k) # Reverse mean mu_theta.
187
- if step > 1:
188
- variance = float(schedule["posterior_variance"][step])
189
- z = mean + math.sqrt(variance) * torch.randn(z.shape, generator=generator)
190
- else:
191
- z = mean
192
- return z # [N, 2].
193
-
194
-
195
- # %% 07. The report
196
- def main() -> None:
197
- parser = common_parser(__doc__.split("\n")[0])
198
- parser.set_defaults(steps=200, samples=2048)
199
- args = parser.parse_args()
200
- set_seed(args.seed)
201
- device = pick_device(args.device) # Two coordinates per point; a CPU is enough.
202
- schedule = schedule_tensors(args.steps)
203
-
204
- def run(**settings) -> list:
205
- generator = torch.Generator().manual_seed(args.seed) # The same starting noise each time.
206
- points = sample_diffusion(schedule, args.samples, TARGET_LABEL, generator=generator,
207
- **settings)
208
- return mode_report(points)["mode_fractions"]
209
-
210
- # The conditional score here is exact, so at w = 1 the condition already pins the mode and
211
- # the fractions saturate. The graded part of the sweep therefore sits below one.
212
- report = {}
213
- for w in (0.0, 0.1, 0.25, 0.5, 1.0): # The target mode is the first entry of each list.
214
- report[f"mode fractions at w={w}"] = run(w=w)
215
- report["mode fractions, w=1 scheduled"] = run(w=1.0, scheduled=True)
216
- for eta in (0.5, 2.0): # Reward steering toward the two right-hand modes.
217
- report[f"mode fractions at eta={eta}, lambda=(1,0)"] = run(eta=eta, lambdas=(1.0, 0.0))
218
- report["mode fractions at eta=2, lambda=(0.7,0.3)"] = run(eta=2.0, lambdas=(0.7, 0.3))
219
-
220
- # The two routes to one guided noise prediction, on a batch at a middle level.
221
- probe = torch.randn(256, 2, generator=torch.Generator().manual_seed(args.seed + 1))
222
- a_t, sigma_t = 0.8, 0.6
223
- by_classifier = guided_noise(-sigma_t * unconditional_score(probe, a_t),
224
- classifier_gradient(probe, a_t, TARGET_LABEL), 2.0, sigma_t)
225
- by_difference = classifier_free_field(-sigma_t * unconditional_score(probe, a_t),
226
- -sigma_t * conditional_score(probe, a_t, TARGET_LABEL),
227
- 2.0)
228
- gap = float((by_classifier - by_difference).abs().max())
229
- report["classifier and classifier-free gap"] = gap
230
-
231
- # The chapter's worked numbers, none of which needs a sampler.
232
- score, gradient = torch.tensor([[-1.0, -0.1]]), torch.tensor([[1.3, 1.1]])
233
- for gamma in (1.0, 2.0):
234
- direction = guided_score(score, gradient, gamma)[0]
235
- report[f"guided direction at gamma={gamma}"] = [round(float(v), 4) for v in direction]
236
- report[f"its length and angle at gamma={gamma}"] = [
237
- round(float(direction.norm()), 4),
238
- round(math.degrees(math.atan2(float(direction[1]), float(direction[0]))), 1),
239
- ]
240
- cfg_eps = float(classifier_free_field(torch.tensor(0.50), torch.tensor(0.10), 2.0))
241
- report["classifier-free eps at w=2"] = cfg_eps # -0.30, past the conditional 0.10.
242
- report["its implied score"] = -cfg_eps / 0.5 # 0.6, at sigma_t = 0.5.
243
-
244
- # One guided backward step from the state of the deterministic sampler, through both
245
- # parameterizations. A disagreement between them is a missing sigma_t or a missing one half.
246
- x_t, beta_t, sigma_t, eps_theta = 1.2, 2.0, 0.5, 0.8 # The state of Section 3.35.
247
- gradient, gamma, dt = 0.6, 2.0, 0.1 # The classifier gradient, the scale and the step.
248
- unguided = -0.5 * beta_t * (x_t - eps_theta / sigma_t) # 0.4, the unguided velocity.
249
- guided = unguided - 0.5 * gamma * beta_t * gradient # -0.8, half of gamma g^2 on the gradient.
250
- guided_eps = float(guided_noise(torch.tensor(eps_theta), torch.tensor(gradient),
251
- gamma, sigma_t)) # 0.2, the same guidance in the noise.
252
- report["guided velocity and next state"] = [round(guided, 4),
253
- round(x_t - dt * guided, 4)] # -0.8 and 1.28.
254
- report["guided eps and its velocity"] = [
255
- round(guided_eps, 4), # 0.2, whose implied score is -0.4.
256
- round(-0.5 * beta_t * (x_t - guided_eps / sigma_t), 4), # -0.8, the same velocity.
257
- ]
258
-
259
- # Two candidates under two weightings, and two objectives that disagree.
260
- candidates = ((1.2, 0.2), (0.5, 1.1)) # Normalized binding and solubility of two peptides.
261
- for lambdas in ((0.7, 0.3), (0.3, 0.7)):
262
- report[f"candidate rewards, lambda={lambdas}"] = [round(scalarize(v, lambdas), 4)
263
- for v in candidates]
264
- competing = 0.6 * torch.tensor([1.0, 0.8]) + 0.4 * torch.tensor([0.5, -1.2])
265
- report["competing gradients combined"] = [round(float(v), 4) for v in competing] # (0.8, 0).
266
- rewards = torch.tensor([1.2, 0.8, 0.3])
267
- report["selection weights at eta=2"] = [round(float(p), 4)
268
- for p in (2.0 * rewards).softmax(0)]
269
- report["schedule value at t=0.6"] = schedule_value(0.6) # 0.96.
270
-
271
- if not args.quiet:
272
- print(f"device={device} points={args.samples} steps={args.steps} "
273
- f"target mode={GAUSSIAN_CENTERS[TARGET_LABEL].tolist()}")
274
- print_report("Guidance on the four-Gaussian target", report)
275
- save_run(args.out, config=vars(args), report=report)
276
-
277
-
278
- # %% 08. Run the script
279
- if __name__ == "__main__":
280
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_3/latent_diffusion.py DELETED
@@ -1,309 +0,0 @@
1
- """AMP-Diffusion, the same chain run in the latent space of a protein language model.
2
-
3
- Gaussian corruption needs a state that can be added to, and an amino acid sequence has no such
4
- structure. A frozen encoder supplies one. The peptide becomes a tensor of residue embeddings, the
5
- diffusion runs there, and the language-model head maps the generated latent back to residues.
6
- Against the image model the state is the latent, a transformer replaces the U-Net, the network
7
- predicts the clean latent in place of the noise, and a mask marks which positions hold real
8
- residues.
9
-
10
- Chapter 3 runs its diffusion clock from the data, so step k = 0 holds a clean latent and
11
- corruption grows with k, up to pure noise at k = K.
12
-
13
- Chapter 3, Sections 3.21, 3.29 and 3.46 of the notes. Trained on toy embeddings by default, and
14
- on ESM-2 embeddings of the course peptides with --dataset esm2. Prints the corrupted coordinate at
15
- k = 500, one reverse step there, the masked loss, and the polar fraction before and after the
16
- constraint.
17
-
18
- python lecture_3/latent_diffusion.py # 800 steps on offline embeddings
19
- python lecture_3/latent_diffusion.py --steps 20 # a few seconds, for a first look
20
- python lecture_3/latent_diffusion.py --dataset esm2 # real ESM-2 latents and its head
21
- """
22
-
23
- # %% 01. Imports and configuration
24
- import math
25
-
26
- import torch
27
- import torch.nn as nn
28
-
29
- from cis6270.nets import count_parameters
30
- from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
31
-
32
- MODEL_NAME = "facebook/esm2_t33_650M_UR50D" # Pretrained ESM-2 encoder.
33
- EMBED_DIM = 1280 # ESM-2 residue embedding width D.
34
- MAX_LEN = 32 # Token positions L per padded peptide.
35
- NUM_LAYERS = 6 # Transformer layers in the denoiser.
36
- NUM_STEPS = 1000 # Diffusion steps K.
37
- AMINO_ACIDS = "ACDEFGHIKLMNPQRSTVWY" # The 20 standard amino acids.
38
- POLAR_RESIDUES = set("STNQYCHKRDE") # Polar or charged side chains.
39
- POLAR_COLUMNS = [AMINO_ACIDS.index(a) for a in "STNQYCHKRDE"]
40
-
41
-
42
- # %% 02. The schedule tensors
43
- #
44
- # The image tensors of Section 3.28, rebuilt here so the file stands alone. Index k holds the
45
- # quantity at step k, with a zero prefixed so that abar_0 = 1 at the clean latent.
46
-
47
- betas = torch.cat([torch.zeros(1), torch.linspace(1e-4, 0.02, NUM_STEPS)]) # beta_k.
48
- alphas = 1.0 - betas # One-step retention alpha_k.
49
- alphas_cumprod = torch.cumprod(alphas, dim=0) # Cumulative retention abar_k.
50
- alphas_cumprod_prev = torch.cat([torch.ones(1), alphas_cumprod[:-1]]) # abar_{k-1}.
51
- posterior_variance = betas * (1.0 - alphas_cumprod_prev) / (1.0 - alphas_cumprod) # beta-tilde_k.
52
-
53
-
54
- # %% 03. Masked corruption
55
- #
56
- # Z_k = (sqrt(abar_k) Z_0 + sqrt(1 - abar_k) eps) * M. The reshape to [B, 1, 1] broadcasts one
57
- # coefficient per sequence across all L positions and all D coordinates, and the mask holds padded
58
- # positions at exactly zero so the denoiser spends no capacity on them.
59
-
60
-
61
- def q_sample_latent(z0, k, mask, noise=None):
62
- if noise is None:
63
- noise = torch.randn_like(z0) # [B, L, D] standard Gaussian.
64
- abar = alphas_cumprod.to(z0.device)[k].reshape(-1, 1, 1)
65
- z_k = abar.sqrt() * z0 + (1.0 - abar).sqrt() * noise
66
- return z_k * mask.unsqueeze(-1), noise # Padding stays exactly zero.
67
-
68
-
69
- def timestep_embedding(k, dim):
70
- half = dim // 2 # Half the coordinates sine, half cosine.
71
- freqs = torch.exp(-math.log(10000.0)
72
- * torch.arange(half, device=k.device).to(torch.get_default_dtype())
73
- / half)
74
- args = k.to(torch.get_default_dtype())[:, None] * freqs[None, :] # [B, half].
75
- return torch.cat([torch.sin(args), torch.cos(args)], dim=1) # [B, dim].
76
-
77
-
78
- # %% 04. The latent denoiser and its loss
79
- #
80
- # The step embedding, a learned position embedding and the latent are added before the transformer
81
- # acts on them, so the step reaches every residue as one shared offset and the attention layers mix
82
- # the positions. The loss counts valid residues only.
83
-
84
-
85
- class LatentDenoiser(nn.Module):
86
- def __init__(self, embed_dim=EMBED_DIM, num_layers=NUM_LAYERS,
87
- num_heads=8, max_len=MAX_LEN):
88
- super().__init__()
89
- self.embed_dim = embed_dim
90
- self.time_mlp = nn.Sequential( # Same sinusoidal step embedding.
91
- nn.Linear(embed_dim, embed_dim),
92
- nn.SiLU(),
93
- nn.Linear(embed_dim, embed_dim),
94
- )
95
- self.pos_embed = nn.Embedding(max_len, embed_dim) # Residue index.
96
- encoder_layer = nn.TransformerEncoderLayer(
97
- d_model=embed_dim, nhead=num_heads,
98
- dim_feedforward=4 * embed_dim, batch_first=True,
99
- )
100
- self.transformer = nn.TransformerEncoder(
101
- encoder_layer, num_layers=num_layers,
102
- enable_nested_tensor=False, # Keep a padded batch on the ordinary attention path.
103
- )
104
- self.out = nn.Sequential(nn.LayerNorm(embed_dim),
105
- nn.Linear(embed_dim, embed_dim))
106
-
107
- def forward(self, z_k, k, attention_mask):
108
- t_emb = self.time_mlp(timestep_embedding(k, self.embed_dim))
109
- positions = torch.arange(z_k.shape[1], device=z_k.device)
110
- h = (z_k
111
- + t_emb.unsqueeze(1) # Shared across positions.
112
- + self.pos_embed(positions).unsqueeze(0)) # Shared across batch.
113
- h = self.transformer(h, src_key_padding_mask=~attention_mask.bool())
114
- return self.out(h) # Predicted clean latent Z0-hat.
115
-
116
-
117
- def latent_loss(z0_hat, z0, mask):
118
- error = (z0_hat - z0) ** 2 # [B, L, D] squared error.
119
- error = error * mask.unsqueeze(-1) # Zero out padded positions.
120
- return error.sum() / (mask.sum() * z0.shape[-1]) # Valid tokens only.
121
-
122
-
123
- # %% 05. Reverse latent sampling
124
- #
125
- # The network predicts the clean latent, so the noise it implies is recovered by rearranging the
126
- # corruption equation, and the reverse mean is then the image sampler's expression unchanged.
127
-
128
-
129
- @torch.no_grad()
130
- def p_sample_latent(model, z_k, k, mask):
131
- z0_hat = model(z_k, k, mask) # Predict the clean latent.
132
- abar = alphas_cumprod.to(z_k.device)[k].reshape(-1, 1, 1)
133
- eps_hat = (z_k - abar.sqrt() * z0_hat) / (1.0 - abar).sqrt()
134
- beta_k = betas.to(z_k.device)[k].reshape(-1, 1, 1)
135
- alpha_k = alphas.to(z_k.device)[k].reshape(-1, 1, 1)
136
- mean = (z_k - beta_k / (1.0 - abar).sqrt() * eps_hat) / alpha_k.sqrt()
137
- var = posterior_variance.to(z_k.device)[k].reshape(-1, 1, 1)
138
- xi = torch.randn_like(z_k)
139
- keep = (k > 1).to(torch.get_default_dtype()).reshape(-1, 1, 1)
140
- return (mean + keep * var.sqrt() * xi) * mask.unsqueeze(-1)
141
-
142
-
143
- @torch.no_grad()
144
- def sample_latents(model, num_sequences, length, width, device):
145
- mask = torch.ones(num_sequences, length, device=device)
146
- z = torch.randn(num_sequences, length, width, device=device)
147
- for step in range(NUM_STEPS, 0, -1): # Z_K down to Z_0.
148
- k = torch.full((num_sequences,), step, device=device,
149
- dtype=torch.long)
150
- z = p_sample_latent(model, z, k, mask)
151
- return z
152
-
153
-
154
- # %% 06. Decoding back to amino acids
155
- #
156
- # The generated latent is unstandardized first, then read through the head that was trained to
157
- # score residues from exactly these embeddings. The head also scores special tokens and ambiguity
158
- # codes, so the columns are restricted to the twenty canonical amino acids before the argmax.
159
-
160
-
161
- @torch.no_grad()
162
- def decode(z, head, columns, z_mean, z_std):
163
- latent = z * z_std + z_mean # Undo normalization.
164
- logits = head(latent) # Full vocabulary logits.
165
- logits = logits.index_select(-1, columns) # 20 canonical residues.
166
- scores, choices = logits.max(dim=-1) # Argmax at each position.
167
- return logits, choices
168
-
169
-
170
- def composition_proxies(seq):
171
- """Net charge per residue and polar/charged fraction."""
172
- L = max(len(seq), 1)
173
- charge = (seq.count("K") + seq.count("R")
174
- - seq.count("D") - seq.count("E")) / L
175
- polar = sum(1 for a in seq if a in POLAR_RESIDUES) / L
176
- return charge, polar
177
-
178
-
179
- # %% 07. The composition constraint at decoding
180
- #
181
- # Guidance makes a high polar fraction more likely without guaranteeing it. The requirement is
182
- # imposed afterwards by substituting residues at the positions where substitution costs the
183
- # least logit. One projection at the end satisfies the requirement exactly, which a penalty
184
- # added to the reward along the way does not.
185
-
186
-
187
- def enforce_polar(logits, choices, minimum):
188
- cols = torch.tensor(POLAR_COLUMNS, device=logits.device)
189
- polar_scores, pick = logits.index_select(-1, cols).max(dim=-1)
190
- polar_choices = cols[pick] # best polar residue
191
- already = torch.isin(choices, cols) # positions already polar
192
- sequences = []
193
- for b in range(choices.shape[0]):
194
- row = choices[b].clone()
195
- missing = minimum - int(already[b].sum())
196
- if missing > 0:
197
- chosen = logits[b].gather(1, row[:, None]).squeeze(1)
198
- cost = chosen - polar_scores[b] # logit lost by swapping
199
- cost = cost.masked_fill(already[b], float("inf"))
200
- _, swap = cost.topk(missing, largest=False)
201
- row[swap] = polar_choices[b][swap]
202
- sequences.append("".join(AMINO_ACIDS[i] for i in row.tolist()))
203
- return sequences
204
-
205
-
206
- # %% 08. The latents, standardized
207
- #
208
- # ESM-2 coordinates have means and scales of their own, and a corruption that drove such a cloud
209
- # toward N(0, I) would have to destroy a systematic offset as well as the structure. Each
210
- # coordinate is therefore centered and scaled over the valid residues before any diffusion runs,
211
- # and the two statistics are kept for decoding.
212
-
213
-
214
- def load_latents(dataset: str, device, seed: int):
215
- """Return the standardized latents, the mask, the decoding head and its residue columns."""
216
- if dataset == "toy":
217
- from cis6270.data import standardize, toy_embeddings
218
-
219
- z0, mask = toy_embeddings(num_sequences=128, length=16, width=32, seed=seed)
220
- z0, z_mean, z_std = standardize(z0, mask)
221
- # Without ESM-2 there is no language-model head, so a fixed projection stands in. It makes
222
- # the decoding path and the constraint runnable offline; its residues carry no biology.
223
- generator = torch.Generator().manual_seed(seed)
224
- weight = torch.randn(len(AMINO_ACIDS), z0.shape[-1], generator=generator) / 4.0
225
- head = lambda latent: latent @ weight.t().to(latent) # noqa: E731
226
- columns = torch.arange(len(AMINO_ACIDS))
227
- else:
228
- from cis6270.data import esm2_embeddings, standardize
229
-
230
- z0, mask, tokenizer, head = esm2_embeddings(
231
- model_name=MODEL_NAME, max_length=MAX_LEN, device=device,
232
- ) # The head is ESM-2's masked language-model head, frozen with the encoder.
233
- z0, z_mean, z_std = standardize(z0, mask)
234
- columns = torch.tensor([tokenizer.convert_tokens_to_ids(a) for a in AMINO_ACIDS])
235
- return z0.to(device), mask.to(device), z_mean.to(device), z_std.to(device), head, columns
236
-
237
-
238
- # %% 09. The report
239
- def main() -> None:
240
- parser = common_parser(__doc__.split("\n")[0])
241
- parser.add_argument("--dataset", choices=["toy", "esm2"], default="toy")
242
- parser.add_argument("--minimum-polar", type=int, default=8, help="polar residues required")
243
- parser.set_defaults(steps=800, batch_size=16, lr=1e-3, samples=8)
244
- args = parser.parse_args()
245
- set_seed(args.seed)
246
- device = pick_device(args.device)
247
-
248
- z0_all, mask_all, z_mean, z_std, head, columns = load_latents(args.dataset, device, args.seed)
249
- num_sequences, length, width = z0_all.shape # [N, L, D].
250
- denoiser = LatentDenoiser(embed_dim=width, num_layers=NUM_LAYERS, num_heads=8,
251
- max_len=length).to(device)
252
- if not args.quiet:
253
- print(f"device={device} dataset={args.dataset} latents={tuple(z0_all.shape)} "
254
- f"parameters={count_parameters(denoiser):,}")
255
-
256
- def loss_fn(step: int) -> torch.Tensor:
257
- rows = torch.randint(0, num_sequences, (args.batch_size,), device=device) # [B].
258
- z0, mask = z0_all[rows], mask_all[rows] # [B, L, D] and [B, L].
259
- k = torch.randint(1, NUM_STEPS + 1, (z0.shape[0],), device=device) # One step per item.
260
- z_k, _ = q_sample_latent(z0, k, mask) # Corrupt the clean latent.
261
- return latent_loss(denoiser(z_k, k, mask), z0, mask) # Valid residues only.
262
-
263
- losses = train(denoiser, loss_fn, args.steps, args.lr, quiet=args.quiet)
264
- generated = sample_latents(denoiser, args.samples, length, width, device) # [S, L, D].
265
- logits, choices = decode(generated, head, columns.to(device), z_mean, z_std)
266
- # The argmax decoding before the constraint, and the same sequences after it.
267
- plain = ["".join(AMINO_ACIDS[int(i)] for i in row) for row in choices]
268
- constrained = enforce_polar(logits, choices, args.minimum_polar)
269
-
270
- # The chapter's coordinate, corrupted at k = 500 and carried back one reverse step.
271
- abar = float(alphas_cumprod[500])
272
- a_500, sigma_500 = math.sqrt(abar), math.sqrt(1.0 - abar) # 0.2803 and 0.9599.
273
- z_500 = a_500 * 1.2 + sigma_500 * 0.4 # 0.7204.
274
- eps_hat = (z_500 - a_500 * 1.05) / sigma_500 # 0.4438 from the prediction z0_hat = 1.05.
275
- mean_500 = (z_500 - float(betas[500]) / sigma_500 * eps_hat) / math.sqrt(float(alphas[500]))
276
- # The nine substitution costs of the notes, with two positions still to fill.
277
- costs = torch.tensor([0.9, 1.4, 2.6, 3.1, 3.3, 4.0, 4.2, 5.5, 6.1])
278
-
279
- window = min(50, args.steps) # A short run averages over everything it has.
280
- report = {
281
- "parameters": count_parameters(denoiser),
282
- f"loss, first {window} steps": sum(losses[:window]) / window,
283
- f"loss, last {window} steps": sum(losses[-window:]) / window,
284
- "valid residues per batch": int(mask_all[:args.batch_size].sum()),
285
- "a_500 and sigma_500": [round(a_500, 4), round(sigma_500, 4)], # 0.2803 and 0.9599.
286
- "z_500 from z_0=1.2, eps=0.4": z_500, # 0.7204.
287
- "eps implied by z0_hat=1.05": eps_hat, # 0.4438, against the draw 0.4.
288
- "reverse mean at k=500": mean_500, # 0.7193.
289
- "reverse standard deviation": math.sqrt(float(posterior_variance[500])), # 0.1002.
290
- "clean estimate at a=0.8 from eps=-0.8": (7.4 + 0.6 * 0.8) / 0.8, # 9.85.
291
- "noise recovered from it": (7.4 - 0.8 * 9.85) / 0.6, # -0.8.
292
- "polar fraction, plain decoding": sum(
293
- composition_proxies(s)[1] for s in plain) / len(plain),
294
- "polar fraction, constrained": sum(
295
- composition_proxies(s)[1] for s in constrained) / len(constrained),
296
- "net charge per residue, constrained": sum(
297
- composition_proxies(s)[0] for s in constrained) / len(constrained),
298
- "two cheapest substitutions": [round(float(v), 1)
299
- for v in costs.topk(2, largest=False)[0]],
300
- "logit surrendered": float(costs.topk(2, largest=False)[0].sum()), # 2.3.
301
- }
302
- print_report(f"AMP-Diffusion on {args.dataset} latents", report, constrained)
303
- save_run(args.out, model=denoiser, config=vars(args), report=report, losses=losses,
304
- samples=constrained)
305
-
306
-
307
- # %% 10. Run the script
308
- if __name__ == "__main__":
309
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_3/samplers.py DELETED
@@ -1,247 +0,0 @@
1
- """Three samplers reading one score.
2
-
3
- Once a score is available, generation can take three forms. The reverse-time equation discretized
4
- on a grid gives ancestral DDPM sampling, which injects fresh noise at every step. Rewriting the
5
- Laplacian of the Fokker-Planck equation as a divergence gives the probability-flow ODE, which
6
- reproduces the same marginals with no noise at all. Holding one density fixed and running its
7
- score as a drift gives Langevin dynamics, which needs a ladder of noise levels to cross between
8
- modes. The target here is the four-Gaussian mixture, whose corrupted score is a closed form, so
9
- all three run on the same exact field and nothing is trained.
10
-
11
- Chapter 3 runs its diffusion clock from the data, so t = 0 holds a clean sample and corruption
12
- grows with t. Generation traverses the same interval from t = 1 back to t = 0.
13
-
14
- Chapter 3, Sections 3.22, 3.26, 3.32, 3.34 and 3.35 of the notes. Prints the mode coverage of each
15
- sampler, the number of score evaluations each spends, and the chapter's one backward step from
16
- x = 1.2 under both dynamics.
17
-
18
- python lecture_3/samplers.py # 200 steps for each of the three
19
- python lecture_3/samplers.py --steps 20 # a coarse grid, for a first look
20
- """
21
-
22
- # %% 01. Imports and configuration
23
- import math
24
-
25
- import torch
26
-
27
- from cis6270.data import GAUSSIAN_CENTERS, GAUSSIAN_STD
28
- from cis6270.metrics import mean_log_density, mode_report
29
- from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed
30
-
31
- BETA_MIN = 0.1 # beta(0) of the linear variance-preserving schedule.
32
- BETA_MAX = 20.0 # beta(1) of the same schedule.
33
- LADDER_START = 0.7 # Noisiest level of the Langevin ladder, in diffusion time.
34
- LADDER_END = 0.01 # Least noisy level of the ladder.
35
-
36
-
37
- # %% 02. The target and its exact score at any noise level
38
- #
39
- # Corrupting a Gaussian mixture by X_t = a_t X_0 + sigma_t eps gives a Gaussian mixture with means
40
- # a_t mu_m and the common variance a_t^2 s^2 + sigma_t^2, so the score of p_t is a closed form at
41
- # every level. Every call is counted, because the cost of a sampler is the number of evaluations
42
- # a trained network would have served.
43
-
44
-
45
- def beta(t: float) -> float:
46
- """The noising rate beta(t) = 0.1 + 19.9 t of the linear schedule."""
47
- return BETA_MIN + (BETA_MAX - BETA_MIN) * t
48
-
49
-
50
- def signal(t: float) -> float:
51
- """a_t = exp(-B(t)/2) with B(t) = 0.1 t + 9.95 t^2, the accumulated noising."""
52
- return math.exp(-(BETA_MIN * t + 0.5 * (BETA_MAX - BETA_MIN) * t ** 2) / 2.0)
53
-
54
-
55
- class ExactScore:
56
- """The score of p_t at a given signal coefficient, with a counter on the calls."""
57
-
58
- def __init__(self):
59
- self.calls = 0 # One call stands for one network evaluation.
60
-
61
- def __call__(self, x: torch.Tensor, a_t: float) -> torch.Tensor:
62
- """x: [B, 2], a_t the signal coefficient -> [B, 2]."""
63
- self.calls += 1
64
- variance = a_t ** 2 * GAUSSIAN_STD ** 2 + (1.0 - a_t ** 2) # Width of p_t.
65
- offset = x[:, None, :] - a_t * GAUSSIAN_CENTERS.to(x)[None, :, :] # [B, 4, 2].
66
- log_weight = -(offset ** 2).sum(dim=2) / (2.0 * variance) # [B, 4] equal mixture weights.
67
- responsibility = log_weight.softmax(dim=1)[:, :, None] # [B, 4, 1].
68
- return (responsibility * (-offset / variance)).sum(dim=1) # [B, 2].
69
-
70
-
71
- def make_schedule(num_steps: int) -> dict:
72
- """The DDPM tensors on a grid of K steps, read off the continuous schedule.
73
-
74
- Setting abar_k = a_{t_k}^2 at t_k = k/K and recovering alpha_k = abar_k / abar_{k-1} matches
75
- the continuous marginals at every grid point. At K = 1000 the betas agree with the linear
76
- sequence from 1e-4 to 0.02 to the accuracy of log(1 - beta) = -beta, and at a coarse K every
77
- beta_k stays below one, which beta(t_k)/K does not.
78
- """
79
- grid = (torch.arange(0, num_steps + 1).to(torch.get_default_dtype())
80
- / num_steps) # [K+1] the times t_k, with t_0 = 0.
81
- alphas_cumprod = torch.exp(
82
- -(BETA_MIN * grid + 0.5 * (BETA_MAX - BETA_MIN) * grid ** 2)
83
- ) # [K+1] abar_k, with abar_0 = 1 at the clean state.
84
- alphas_cumprod_prev = torch.cat([torch.ones(1), alphas_cumprod[:-1]]) # abar_{k-1}.
85
- alphas = alphas_cumprod / alphas_cumprod_prev # One-step retention alpha_k.
86
- betas = 1.0 - alphas # Per-step variance beta_k, zero at the placeholder index 0.
87
- return {
88
- "betas": betas, "alphas": alphas, "alphas_cumprod": alphas_cumprod,
89
- "posterior_variance": betas * (1.0 - alphas_cumprod_prev) / (1.0 - alphas_cumprod),
90
- }
91
-
92
-
93
- # %% 03. Ancestral DDPM sampling
94
- #
95
- # x_{k-1} = mu_theta(x_k, k) + sqrt(beta-tilde_k) z with the reverse mean built from the noise
96
- # prediction, eps = -sigma_k s_k. Fresh noise enters at every step except the last.
97
-
98
-
99
- @torch.no_grad()
100
- def ancestral_sample(score, schedule, num_points: int, generator=None) -> torch.Tensor:
101
- """Run the reverse chain from X_K ~ N(0, I) down to X_0. Returns [N, 2]."""
102
- num_steps = len(schedule["betas"]) - 1
103
- x = torch.randn(num_points, 2, generator=generator) # [N, 2] the terminal Gaussian.
104
- for step in range(num_steps, 0, -1): # k = K down to k = 1.
105
- abar_k = float(schedule["alphas_cumprod"][step])
106
- sigma_k = math.sqrt(1.0 - abar_k) # Corruption standard deviation at this step.
107
- eps_theta = -sigma_k * score(x, math.sqrt(abar_k)) # [N, 2] the noise the score implies.
108
- mean = (x - float(schedule["betas"][step]) / sigma_k * eps_theta) / math.sqrt(
109
- float(schedule["alphas"][step])
110
- ) # Reverse mean mu_theta.
111
- if step > 1: # No noise at the final step.
112
- variance = float(schedule["posterior_variance"][step])
113
- x = mean + math.sqrt(variance) * torch.randn(x.shape, generator=generator)
114
- else:
115
- x = mean
116
- return x # [N, 2].
117
-
118
-
119
- # %% 04. The probability-flow ODE
120
- #
121
- # v_t(x) = -(1/2) beta(t) [x + s_t(x)] has the same marginals with no Brownian increment, and
122
- # generation integrates it backward, x_{t - dt} = x_t - dt v_t(x_t). Once X_T is drawn the whole
123
- # trajectory is fixed.
124
-
125
-
126
- @torch.no_grad()
127
- def probability_flow_sample(score, num_points: int, steps: int, generator=None) -> torch.Tensor:
128
- """Euler integration of the probability-flow ODE from t = 1 to t = 0. Returns [N, 2]."""
129
- x = torch.randn(num_points, 2, generator=generator) # [N, 2] the terminal Gaussian.
130
- dt = 1.0 / steps # Positive step, taken in the direction of decreasing t.
131
- for step in range(steps, 0, -1):
132
- t = step * dt
133
- a_t = signal(t)
134
- velocity = -0.5 * beta(t) * (x + score(x, a_t)) # [N, 2] forward-time velocity.
135
- x = x - dt * velocity # One backward Euler step.
136
- return x # [N, 2].
137
-
138
-
139
- # %% 05. Langevin dynamics and the noise ladder
140
- #
141
- # dX = s(X) d_tau + sqrt(2) dW holds p stationary at one fixed density, and the Euler-Maruyama
142
- # update is x' = x + eta s(x) + sqrt(2 eta) z. A chain run at the data density alone crosses
143
- # between modes slowly, so the levels are visited in decreasing order of corruption, with a few
144
- # iterations at each.
145
-
146
-
147
- @torch.no_grad()
148
- def annealed_langevin_sample(score, num_points, levels, per_level, scale, generator=None):
149
- """Run ``per_level`` Langevin iterations at each level of the ladder. Returns [N, 2]."""
150
- x = torch.randn(num_points, 2, generator=generator) # [N, 2] a Gaussian start.
151
- for t in levels:
152
- a_t = signal(t)
153
- width = a_t ** 2 * GAUSSIAN_STD ** 2 + (1.0 - a_t ** 2) # Width of p_t at this level.
154
- eta = scale * width # Step size tied to the level, large when the density is broad.
155
- for _ in range(per_level):
156
- noise = torch.randn(x.shape, generator=generator) # [N, 2] fresh at every iteration.
157
- x = x + eta * score(x, a_t) + math.sqrt(2.0 * eta) * noise
158
- return x # [N, 2] at the least corrupted level of the ladder.
159
-
160
-
161
- def langevin_step(x: float, score_value: float, eta: float, z: float) -> float:
162
- """One Langevin update in one coordinate, the chapter's x + eta s(x) + sqrt(2 eta) z."""
163
- return x + eta * score_value + math.sqrt(2.0 * eta) * z
164
-
165
-
166
- # %% 06. One backward step under both dynamics
167
- #
168
- # The score appears in the reverse stochastic equation with the full g(t)^2 and in the
169
- # deterministic velocity with half of it, which is the only structural difference between the
170
- # two samplers.
171
-
172
-
173
- def one_step_comparison(x_t, beta_t, sigma_t, eps_theta, dt) -> dict:
174
- """The chapter's state carried one step by the ODE and by the reverse-time equation."""
175
- score_value = -eps_theta / sigma_t # The score the noise prediction implies.
176
- velocity = -0.5 * beta_t * (x_t + score_value) # Probability-flow velocity.
177
- reverse_drift = -0.5 * beta_t * x_t - beta_t * score_value # f - g^2 s.
178
- return {
179
- "score": score_value, "velocity": velocity, "ode_next": x_t - dt * velocity,
180
- "sde_drift": reverse_drift, "sde_mean": x_t - dt * reverse_drift,
181
- "sde_noise_std": math.sqrt(beta_t * dt),
182
- }
183
-
184
-
185
- # %% 07. The report
186
- def main() -> None:
187
- parser = common_parser(__doc__.split("\n")[0])
188
- parser.add_argument("--levels", type=int, default=20, help="rungs of the Langevin ladder")
189
- parser.add_argument("--langevin-scale", type=float, default=0.15, help="step size over width")
190
- parser.set_defaults(steps=200, samples=2048)
191
- args = parser.parse_args()
192
- set_seed(args.seed)
193
- device = pick_device(args.device) # Two coordinates per point; a CPU is enough.
194
-
195
- results, counts = {}, {}
196
- generator = torch.Generator().manual_seed(args.seed)
197
- score = ExactScore()
198
- results["ancestral DDPM"] = ancestral_sample(
199
- score, make_schedule(args.steps), args.samples, generator,
200
- )
201
- counts["ancestral DDPM"] = score.calls
202
-
203
- generator = torch.Generator().manual_seed(args.seed) # The same starting noise for each.
204
- score = ExactScore()
205
- results["probability flow"] = probability_flow_sample(
206
- score, args.samples, args.steps, generator,
207
- )
208
- counts["probability flow"] = score.calls
209
-
210
- generator = torch.Generator().manual_seed(args.seed)
211
- score = ExactScore()
212
- per_level = max(args.steps // args.levels, 1) # Spend the same budget as the other two.
213
- ladder = [LADDER_START + (LADDER_END - LADDER_START) * i / (args.levels - 1)
214
- for i in range(args.levels)] # Decreasing corruption.
215
- results["annealed Langevin"] = annealed_langevin_sample(
216
- score, args.samples, ladder, per_level, args.langevin_scale, generator,
217
- )
218
- counts["annealed Langevin"] = score.calls
219
-
220
- report = {}
221
- for name, points in results.items():
222
- measured = mode_report(points)
223
- report[f"{name}: score evaluations"] = counts[name]
224
- report[f"{name}: modes found"] = measured["modes_found"]
225
- report[f"{name}: mode entropy"] = measured["mode_entropy"] # log 4 = 1.386 when even.
226
- report[f"{name}: mode fractions"] = measured["mode_fractions"]
227
- report[f"{name}: distance to mode"] = measured["nearest_distance"] # 0.44 on the target.
228
- report[f"{name}: mean log density"] = mean_log_density(points)
229
-
230
- step = one_step_comparison(1.2, 2.0, 0.5, 0.8, 0.1)
231
- report["score at the worked state"] = step["score"] # -1.6.
232
- report["probability-flow velocity"] = step["velocity"] # 0.4.
233
- report["ODE next state"] = step["ode_next"] # 1.16.
234
- report["reverse-time drift"] = step["sde_drift"] # 2.0.
235
- report["reverse-time mean"] = step["sde_mean"] # 1.0.
236
- report["reverse-time noise std"] = step["sde_noise_std"] # 0.4472.
237
- report["Langevin step from x=2"] = langevin_step(2.0, -2.0, 0.01, 0.5) # 2.0507.
238
-
239
- if not args.quiet:
240
- print(f"device={device} points={args.samples} steps={args.steps} seed={args.seed}")
241
- print_report("Three samplers on one exact score", report)
242
- save_run(args.out, config=vars(args), report=report)
243
-
244
-
245
- # %% 08. Run the script
246
- if __name__ == "__main__":
247
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_3/score_matching.py DELETED
@@ -1,302 +0,0 @@
1
- """Three objectives that fit the same score field.
2
-
3
- The loss we would like to minimize, the squared distance from a model to the gradient of the log
4
- data density, holds a target that a sample does not give us. One integration by parts removes that
5
- target and leaves the squared norm of the model plus its divergence. One Gaussian corruption
6
- removes it a second way and leaves a plain regression on the noise we drew. Both forms have the
7
- minimizer of the first, and all three are fitted here on a one-dimensional Gaussian mixture whose
8
- exact score is a closed form to measure them against.
9
-
10
- Chapter 3 runs its diffusion clock from the data, so t = 0 holds a clean sample and corruption
11
- grows with t. The level fitted here is the running one of the chapter, a_t = 0.8 and sigma_t = 0.6.
12
-
13
- Chapter 3, Sections 3.14 through 3.17 of the notes. Prints the three fitted score errors, the
14
- integration-by-parts identity, the minimizer of each objective, the Hutchinson trace estimate, and
15
- the posterior average at x_t = 7.4.
16
-
17
- python lecture_3/score_matching.py # fit all three objectives
18
- python lecture_3/score_matching.py --steps 20 # a few seconds, for a first look
19
- """
20
-
21
- # %% 01. Imports and configuration
22
- import math
23
-
24
- import torch
25
-
26
- from cis6270.nets import VelocityMLP, count_parameters
27
- from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
28
-
29
- A_T = 0.8 # Signal coefficient of the level fitted here.
30
- SIGMA_T = 0.6 # Noise standard deviation at the same level, with a_t^2 + sigma_t^2 = 1.
31
- MIXTURE_MEANS = torch.tensor([-2.0, 2.0]) # The two clean modes.
32
- MIXTURE_WEIGHTS = torch.tensor([0.3, 0.7]) # Their mixture weights.
33
- MIXTURE_STD = 0.5 # Standard deviation of each clean component.
34
- TRACE_DIM = 64 # The chapter's 64-dimensional descriptor, used for the trace estimate.
35
- NAMES = ("explicit", "implicit", "denoising") # The three objectives, in the order derived.
36
-
37
-
38
- # %% 02. The corrupted mixture and its exact score
39
- #
40
- # A Gaussian mixture corrupted by X_t = a_t X_0 + sigma_t eps is a Gaussian mixture again, with
41
- # means a_t mu_m and variance a_t^2 s^2 + sigma_t^2, so its score is a closed form.
42
-
43
- CORRUPTED_MEANS = A_T * MIXTURE_MEANS # [M] means of p_t.
44
- CORRUPTED_VARIANCE = A_T ** 2 * MIXTURE_STD ** 2 + SIGMA_T ** 2 # Shared variance of p_t.
45
-
46
-
47
- def sample_clean(num_points: int, generator=None) -> torch.Tensor:
48
- """Draw clean samples from the mixture. Returns [N, 1]."""
49
- which = torch.multinomial(MIXTURE_WEIGHTS, num_points, True, generator=generator) # [N].
50
- noise = torch.randn(num_points, generator=generator) * MIXTURE_STD # [N].
51
- return (MIXTURE_MEANS[which] + noise)[:, None] # [N, 1] clean points.
52
-
53
-
54
- def exact_score(x: torch.Tensor) -> torch.Tensor:
55
- """The score of the corrupted mixture p_t at each row of x. [N, 1] -> [N, 1]."""
56
- offset = x - CORRUPTED_MEANS[None, :] # [N, M] displacement from each corrupted mean.
57
- log_weight = MIXTURE_WEIGHTS.log()[None, :] - offset ** 2 / (2.0 * CORRUPTED_VARIANCE)
58
- responsibility = log_weight.softmax(dim=1) # [N, M] posterior over the components.
59
- return (responsibility * (-offset / CORRUPTED_VARIANCE)).sum(dim=1, keepdim=True) # [N, 1].
60
-
61
-
62
- def model_score(model, x: torch.Tensor) -> torch.Tensor:
63
- """The network's score at x. One level is fitted here, so the time input is held at a_t."""
64
- return model(x, torch.full((x.shape[0],), A_T)) # [N, 1].
65
-
66
-
67
- def divergence(field, x: torch.Tensor) -> torch.Tensor:
68
- """The divergence of a field, which in one coordinate is the ordinary derivative. [N, 1]."""
69
- x = x.requires_grad_(True) # Differentiate with respect to the input, not the weights.
70
- d_s, = torch.autograd.grad(field(x).sum(), x, create_graph=True) # Trace of the Jacobian.
71
- return d_s
72
-
73
-
74
- # %% 03. Explicit score matching
75
- #
76
- # J(theta) = (1/2) E || s_theta(x) - grad log p(x) ||^2, averaged over samples of p. The target is
77
- # unavailable in general and available here, so this objective is the reference for the other two.
78
-
79
-
80
- def explicit_loss(model, x: torch.Tensor) -> torch.Tensor:
81
- """Half the squared distance between the predicted score and the exact one."""
82
- return 0.5 * (model_score(model, x) - exact_score(x)).square().sum(dim=1).mean()
83
-
84
-
85
- # %% 04. The integration by parts that removes the data score
86
- #
87
- # E_p[s_theta . grad log p] = -E_p[div s_theta], from p grad log p = grad p followed by one
88
- # integration by parts per coordinate, the boundary term vanishing for a density with Gaussian
89
- # tails.
90
-
91
-
92
- def integration_by_parts_gap(field, x: torch.Tensor) -> dict:
93
- """The two sides of the identity on one sample, and the gap between them."""
94
- left = float((field(x) * exact_score(x)).sum(dim=1).mean().detach())
95
- right = float(-divergence(field, x.clone()).sum(dim=1).mean().detach())
96
- return {"left": left, "right": right, "gap": abs(left - right)}
97
-
98
-
99
- # %% 05. Implicit score matching with the trace term
100
- #
101
- # Substituting the identity leaves J(theta) = E[ (1/2) ||s_theta||^2 + div s_theta ] + C, with C a
102
- # constant in theta. On the model s_a(x) = -a x with centered data of variance v, the expectation
103
- # reduces to (1/2) a^2 v - a, whose minimizer is a = 1/v.
104
-
105
-
106
- def implicit_loss(model, x: torch.Tensor) -> torch.Tensor:
107
- """The squared norm of the model plus its divergence, with the data score gone."""
108
- field = lambda z: model_score(model, z) # noqa: E731
109
- s = field(x) # [N, 1].
110
- return (0.5 * s.square().sum(dim=1) + divergence(field, x.clone()).sum(dim=1)).mean()
111
-
112
-
113
- def one_parameter_loss(a: float, variance: float) -> float:
114
- """The implicit loss of s_a(x) = -a x on centered data of the given variance."""
115
- return 0.5 * a ** 2 * variance - a # Minimized at a = 1 / variance.
116
-
117
-
118
- # %% 06. Denoising score matching
119
- #
120
- # For a pair drawn as X_t = a_t X_0 + sigma_t eps the conditional score is known exactly,
121
- # grad log q_t(x | x_0) = -(x - a_t x_0)/sigma_t^2 = -eps/sigma_t. Its posterior average at a fixed
122
- # state is the marginal score, which is therefore the minimizer of the regression.
123
-
124
-
125
- def denoising_loss(model, x_0: torch.Tensor, epsilon: torch.Tensor) -> torch.Tensor:
126
- """Half the squared distance between the predicted score and -eps / sigma_t."""
127
- x_t = A_T * x_0 + SIGMA_T * epsilon # [N, 1] the corrupted state.
128
- return 0.5 * (model_score(model, x_t) + epsilon / SIGMA_T).square().sum(dim=1).mean()
129
-
130
-
131
- # %% 07. One minimizer for the three objectives
132
- #
133
- # Along the family s_a = a s_exact each objective is a quadratic in a, and its minimizer is that
134
- # objective's own numerator over the shared denominator E[||s||^2]. The explicit numerator is
135
- # E[s . grad log p], the implicit one is -E[div s], which the identity of Section 04 makes the
136
- # same number, and the denoising one is E[s . (-eps/sigma_t)], which is that number again because
137
- # the posterior average of the conditional score is the marginal score.
138
-
139
-
140
- def minimizers(x_0, epsilon) -> dict:
141
- """The minimizing a of each objective along the family a s_exact, on one sample."""
142
- x_t = A_T * x_0 + SIGMA_T * epsilon # [N, 1] samples of p_t and the draws that made them.
143
- s = exact_score(x_t) # [N, 1] the exact field of the family.
144
- squared = float(s.square().sum(dim=1).mean()) # E ||s||^2, the shared denominator.
145
- data = float((s * exact_score(x_t)).sum(dim=1).mean()) # E[s . grad log p], the explicit one.
146
- div_s = float(divergence(exact_score, x_t.clone()).detach().sum(dim=1).mean()) # E div s.
147
- target = float((s * (-epsilon / SIGMA_T)).sum(dim=1).mean()) # E[s . (-eps/sigma_t)].
148
- return {"explicit": data / squared, "implicit": -div_s / squared,
149
- "denoising": target / squared}
150
-
151
-
152
- # %% 08. Random directions for the trace
153
- #
154
- # Extracting the d diagonal entries of the Jacobian exactly costs d backward passes, 64 for a
155
- # learned descriptor and 3072 for a colour image on a 32-by-32 grid. Drawing v with E[v v^T] = I
156
- # and averaging v^T (grad s) v returns the same number at one pass per direction.
157
-
158
-
159
- def high_dimensional_score(x: torch.Tensor, means: torch.Tensor, variance: float) -> torch.Tensor:
160
- """The score of a two-component Gaussian mixture in TRACE_DIM coordinates. [N, d] -> [N, d]."""
161
- offset = x[:, None, :] - means[None, :, :] # [N, 2, d].
162
- log_weight = -(offset ** 2).sum(dim=2) / (2.0 * variance) # [N, 2] equal mixture weights.
163
- responsibility = log_weight.softmax(dim=1)[:, :, None] # [N, 2, 1].
164
- return (responsibility * (-offset / variance)).sum(dim=1) # [N, d].
165
-
166
-
167
- def exact_trace(field, x: torch.Tensor) -> tuple[torch.Tensor, int]:
168
- """The divergence coordinate by coordinate, and the number of backward passes it took."""
169
- x = x.clone().requires_grad_(True)
170
- s = field(x) # [N, d].
171
- total = torch.zeros(x.shape[0])
172
- for i in range(x.shape[1]): # One backward pass per coordinate.
173
- column, = torch.autograd.grad(s[:, i].sum(), x, retain_graph=True)
174
- total = total + column[:, i]
175
- return total.detach(), x.shape[1]
176
-
177
-
178
- def hutchinson_trace(field, x: torch.Tensor, probes: int, generator=None):
179
- """Average v^T (grad s) v over ``probes`` random directions, one pass each."""
180
- # A score field has a symmetric Jacobian, the Hessian of a log density, so the
181
- # vector-Jacobian product taken here equals the Jacobian-vector product of the notes.
182
- estimates = []
183
- for _ in range(probes):
184
- v = torch.randn(x.shape, generator=generator) # [N, d] direction with E[v v^T] = I.
185
- point = x.clone().requires_grad_(True)
186
- projected, = torch.autograd.grad((field(point) * v).sum(), point) # [N, d] one pass.
187
- estimates.append((v * projected).sum(dim=1).detach()) # [N] unbiased per direction.
188
- stacked = torch.stack(estimates) # [probes, N].
189
- return stacked.mean(dim=0), stacked
190
-
191
-
192
- # %% 09. The posterior average at x_t = 7.4
193
- #
194
- # The clean value 10 has prior weight 0.3 and the value 9 has 0.7, and at a_t = 0.8 and
195
- # sigma_t = 0.6 both have conditional means near the observation 7.4.
196
-
197
-
198
- def posterior_report(x_t: float, candidates, priors, epsilon_theta: float) -> dict:
199
- """Posterior weights, the clean estimate and the three predictions at one observation."""
200
- candidates, priors = torch.tensor(candidates), torch.tensor(priors) # [C] and [C].
201
- conditional_mean = A_T * candidates # [C] corruption mean of each candidate.
202
- weight = priors * torch.exp(-(x_t - conditional_mean) ** 2 / (2.0 * SIGMA_T ** 2)) # [C].
203
- posterior = weight / weight.sum() # [C] weights after observing x_t.
204
- conditional_score = -(x_t - conditional_mean) / SIGMA_T ** 2 # [C] one per candidate.
205
- marginal = float((posterior * conditional_score).sum()) # The averaged score.
206
- clean_mean = float((posterior * candidates).sum()) # E[X_0 | X_t = x_t].
207
- score_hat = -epsilon_theta / SIGMA_T # The score the chapter's prediction implies.
208
- return {
209
- "weights": [round(float(w), 4) for w in posterior],
210
- "clean_mean": clean_mean,
211
- "conditional_scores": [round(float(s), 4) for s in conditional_score],
212
- "marginal": [marginal, (A_T * clean_mean - x_t) / SIGMA_T ** 2], # Two routes, one number.
213
- # The posterior variance of the target, which no function of x_t alone can remove.
214
- "residual": float((posterior * (marginal - conditional_score) ** 2).sum()),
215
- "score_hat": score_hat,
216
- # The clean estimate from the noise, from the score, and from an optimal network.
217
- "x0": [(x_t - SIGMA_T * epsilon_theta) / A_T, (x_t + SIGMA_T ** 2 * score_hat) / A_T,
218
- (x_t + SIGMA_T ** 2 * marginal) / A_T],
219
- }
220
-
221
-
222
- # %% 10. The report
223
- def main() -> None:
224
- parser = common_parser(__doc__.split("\n")[0])
225
- parser.add_argument("--probes", type=int, default=32, help="random directions for the trace")
226
- parser.set_defaults(steps=250, batch_size=256, width=64, lr=1e-2)
227
- args = parser.parse_args()
228
- set_seed(args.seed)
229
- device = pick_device(args.device) # The fields here are one-dimensional; a CPU is enough.
230
- generator = torch.Generator().manual_seed(args.seed + 1) # Draws for the measurements below.
231
-
232
- objectives = {}
233
- for name in NAMES:
234
- set_seed(args.seed) # Identical initialization for the three fits.
235
- model = VelocityMLP(dim=1, width=args.width, depth=2)
236
- # The training batches restart with the model, so the only difference between the three
237
- # runs is the objective itself.
238
- batches = torch.Generator().manual_seed(args.seed + 3)
239
-
240
- def loss_fn(step: int, name=name, model=model, generator=batches) -> torch.Tensor:
241
- x_0 = sample_clean(args.batch_size, generator) # [B, 1] clean batch.
242
- epsilon = torch.randn(x_0.shape, generator=generator) # [B, 1] corruption draw.
243
- if name == "denoising":
244
- return denoising_loss(model, x_0, epsilon)
245
- x_t = A_T * x_0 + SIGMA_T * epsilon # [B, 1] samples of p_t for the other two.
246
- return explicit_loss(model, x_t) if name == "explicit" else implicit_loss(model, x_t)
247
-
248
- if not args.quiet:
249
- print(f"{name} score matching")
250
- objectives[name] = (model, train(model, loss_fn, args.steps, args.lr, quiet=args.quiet))
251
-
252
- # Every fitted field is measured against the same exact score on one held-out batch.
253
- clean = sample_clean(4096, generator) # [4096, 1].
254
- noise = torch.randn(clean.shape, generator=generator) # [4096, 1].
255
- evaluation = A_T * clean + SIGMA_T * noise # [4096, 1] samples of p_t.
256
- target = exact_score(evaluation)
257
- errors = {n: float((model_score(m, evaluation).detach() - target).square().mean().sqrt())
258
- for n, (m, _) in objectives.items()}
259
-
260
- identity = integration_by_parts_gap(lambda z: model_score(objectives["explicit"][0], z),
261
- evaluation)
262
- wide = torch.Generator().manual_seed(args.seed + 2) # The family scan has its own draws.
263
- scan_clean = sample_clean(32768, wide) # [32768, 1].
264
- scan = minimizers(scan_clean, torch.randn(scan_clean.shape, generator=wide))
265
-
266
- means = torch.randn(2, TRACE_DIM, generator=generator) * 1.5 # [2, d] two distant modes.
267
- points = torch.randn(8, TRACE_DIM, generator=generator) # [8, d] evaluation points.
268
- field = lambda z: high_dimensional_score(z, means, 1.0) # noqa: E731
269
- exact, passes = exact_trace(field, points)
270
- estimate, draws = hutchinson_trace(field, points, args.probes, generator)
271
-
272
- posterior = posterior_report(7.4, (10.0, 9.0), (0.3, 0.7), epsilon_theta=-0.8)
273
- report = {
274
- "parameters per model": count_parameters(objectives["explicit"][0]),
275
- "score error, the three fits": [round(errors[n], 4) for n in NAMES],
276
- "E[s . grad log p] and -E[div s]": [round(identity[k], 4) for k in ("left", "right")],
277
- "integration by parts gap": identity["gap"],
278
- "minimizing a, the three forms": [round(scan[n], 4) for n in NAMES], # All near 1.
279
- # The one-parameter fit at a = 0.5 and a = 1 on unit variance, then at a = 1/v with v = 4.
280
- "one-parameter losses": [round(one_parameter_loss(a, v), 4)
281
- for a, v in ((0.5, 1.0), (1.0, 1.0), (0.25, 4.0))],
282
- "exact trace, backward passes": passes, # 64, one per coordinate.
283
- "exact trace, mean": float(exact.mean()),
284
- "Hutchinson trace, mean": float(estimate.mean()),
285
- "Hutchinson standard error": float(draws.std(dim=0).mean() / math.sqrt(args.probes)),
286
- "posterior weights at x_t=7.4": posterior["weights"], # 0.2156 and 0.7844.
287
- "posterior mean of x_0": posterior["clean_mean"], # 9.2156.
288
- "conditional scores": posterior["conditional_scores"], # 1.6667 and -0.5556.
289
- "marginal score, two routes": [round(v, 4) for v in posterior["marginal"]], # -0.0765.
290
- "irreducible residual": posterior["residual"], # 0.835.
291
- "score from eps_theta=-0.8": posterior["score_hat"], # 1.3333.
292
- "x_0 estimates": [round(v, 4) for v in posterior["x0"]], # 9.85, 9.85 and 9.22.
293
- }
294
- if not args.quiet:
295
- print(f"\ndevice={device} level a_t={A_T} sigma_t={SIGMA_T} seed={args.seed}")
296
- print_report("Three objectives, one score field", report)
297
- save_run(args.out, config=vars(args), report=report, losses=objectives["denoising"][1])
298
-
299
-
300
- # %% 11. Run the script
301
- if __name__ == "__main__":
302
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_3/sde.py DELETED
@@ -1,249 +0,0 @@
1
- """The forward corruption as a stochastic differential equation.
2
-
3
- A deterministic flow is fixed by its velocity at every point. A random path needs one more
4
- ingredient, the perturbation applied over each small interval. A Brownian increment of an interval
5
- of length h has size sqrt(h), one Euler-Maruyama step adds that increment to the Euler step of
6
- Chapter 2, a population started at one point spreads at a rate the noise scale sets, with a
7
- restoring drift that spread stays bounded, and under Ito's lemma and the Fokker-Planck equation
8
- a single drift and noise scale become an equation for the density. No network appears here.
9
-
10
- Chapter 3 runs its diffusion clock from the data, so t = 0 holds a clean sample and corruption
11
- grows with t. Every time in this file is read that way.
12
-
13
- Chapter 3, Sections 3.2 through 3.10 of the notes. Prints the chapter's worked step from x = 2, the
14
- Ornstein-Uhlenbeck mean and variance at t = log 2, the Ito check on E[W_t^2], and the
15
- Fokker-Planck estimate of the Gaussian peak at t = 0.1.
16
-
17
- python lecture_3/sde.py # 400 Euler-Maruyama steps, 16384 paths
18
- python lecture_3/sde.py --steps 20 # a coarse grid, for a first look
19
- """
20
-
21
- # %% 01. Imports and configuration
22
- import math
23
-
24
- import torch
25
-
26
- from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed
27
-
28
- # `--steps` is the number of Euler-Maruyama steps on the grid and `--samples` the number of paths;
29
- # nothing in this file is trained, so those two flags carry the whole cost of a run.
30
- TERMINAL_TIME = 1.0 # T, the end of the simulated interval.
31
-
32
-
33
- # %% 02. Drift and Brownian increments
34
- #
35
- # dX_t = f(X_t, t) dt + g(t) dW_t. The drift f fixes the average displacement over a short interval
36
- # and the Brownian increment dW_t spreads the possible next states around that average. Setting
37
- # g = 0 recovers the ordinary differential equation of Chapter 2, with f as the velocity field.
38
-
39
-
40
- def brownian_increment(h: float, shape: tuple[int, ...], generator=None) -> torch.Tensor:
41
- """One Brownian increment over an interval of length h, drawn as sqrt(h) * epsilon."""
42
- epsilon = torch.randn(shape, generator=generator) # [*shape] standard Gaussian draw.
43
- return math.sqrt(h) * epsilon # Covariance h I, so each coordinate has variance h.
44
-
45
-
46
- # %% 03. The size of a Brownian increment
47
- #
48
- # Multiplying a standard Gaussian by sqrt(h) multiplies its covariance by h, so a coordinate of the
49
- # increment has standard deviation sqrt(h). Halving the interval divides the typical displacement
50
- # by sqrt(2) = 1.414, and the ratio sqrt(h)/h = 1/sqrt(h) grows without bound as the grid refines,
51
- # which is why dW_t appears in the equation and never a velocity dW_t/dt.
52
-
53
-
54
- def increment_scale(h: float, f_value: float = 1.0, g_value: float = 1.0) -> dict:
55
- """The standard deviation of one coordinate of the increment, and two ratios built from it."""
56
- std = math.sqrt(h) # Typical displacement of the noise over an interval of length h.
57
- return {
58
- "std": std,
59
- "std_over_h": std / h, # Grows without bound as the grid refines.
60
- "noise_over_drift": g_value * std / (abs(f_value) * h), # g / (|f| sqrt(h)).
61
- }
62
-
63
-
64
- # %% 04. The Euler-Maruyama step
65
- #
66
- # x_{k+1} = x_k + f(x_k, t_k) h + g(t_k) sqrt(h) epsilon_k. The drift term is the Euler step of
67
- # Chapter 2 unchanged and the noise term is the only addition, with the exact law of a Brownian
68
- # increment over an interval of length h.
69
-
70
-
71
- def euler_maruyama_step(x_k, f_value, g_value, h: float, epsilon):
72
- """One step of the scheme, with the Gaussian draw epsilon_k supplied by the caller."""
73
- drift_part = f_value * h # Displacement the drift alone would produce.
74
- noise_part = g_value * math.sqrt(h) * epsilon # Displacement from this draw.
75
- return x_k + drift_part + noise_part, drift_part, noise_part
76
-
77
-
78
- # %% 05. Fresh randomness and the spreading cloud
79
- #
80
- # Each step draws a new epsilon_k, so two runs from the same state give two trajectories. Started
81
- # from a common point x, the next state is Gaussian with mean x + f h and covariance g^2 h I, so
82
- # the drift moves the center and the noise sets the width. Without a drift the variances of
83
- # independent increments add, so after N steps the variance is N g^2 h = g^2 T and is unbounded.
84
-
85
-
86
- def driftless_cloud(num_paths: int, steps: int, g_value: float, terminal: float, generator=None):
87
- """Simulate f = 0 on a grid of ``steps`` intervals and return the terminal states. [N]."""
88
- h = terminal / steps # Step size, a time.
89
- x = torch.zeros(num_paths) # [N] every path starts at the origin.
90
- for _ in range(steps): # Independent increments accumulate additively in variance.
91
- x = x + g_value * brownian_increment(h, (num_paths,), generator)
92
- return x # [N] terminal states, with variance g^2 T in the limit of many paths.
93
-
94
-
95
- # %% 06. A restoring drift
96
- #
97
- # dX_t = -kappa X_t dt + g dW_t. Multiplying by the integrating factor e^{kappa t} clears the
98
- # drift, and under the Ito isometry the variance of the remaining stochastic integral is an
99
- # ordinary integral, so X_t given x_0 is Gaussian with mean e^{-kappa t} x_0 and variance
100
- # g^2 (1 - e^{-2 kappa t})/(2 kappa). The stationary variance g^2/(2 kappa) is the level at
101
- # which the inward pull of the drift offsets the outward spread of the noise.
102
-
103
-
104
- def ornstein_uhlenbeck_law(x_0: float, kappa: float, g_value: float, t: float) -> dict:
105
- """The exact conditional mean and variance of the Ornstein-Uhlenbeck process at time t."""
106
- decay = math.exp(-kappa * t) # Fraction of the initial signal that remains.
107
- stationary = g_value ** 2 / (2.0 * kappa) # Variance the process settles on.
108
- variance = stationary * (1.0 - decay ** 2) # Rises from zero toward the stationary value.
109
- return {"mean": decay * x_0, "variance": variance, "std": math.sqrt(variance),
110
- "stationary_variance": stationary}
111
-
112
-
113
- def simulate_ornstein_uhlenbeck(num_paths, steps, x_0, kappa, g_value, t, generator=None):
114
- """Euler-Maruyama on the same process, for comparison with the closed form. Returns [N]."""
115
- h = t / steps
116
- x = torch.full((num_paths,), float(x_0)) # [N] all paths start at x_0.
117
- for _ in range(steps):
118
- x = x - kappa * x * h + g_value * brownian_increment(h, (num_paths,), generator)
119
- return x # [N] states at time t.
120
-
121
-
122
- # %% 07. Ito's lemma
123
- #
124
- # d phi(X_t) = (phi_t + f phi_x + (1/2) g^2 phi_xx) dt + g phi_x dW_t. The squared increment
125
- # (Delta X)^2 tends to g^2 Delta t and does not vanish, so the second-order Taylor term is as
126
- # large as the first-order drift term and belongs in the dt part. The chapter's test function is
127
- # phi(x) = x^2 on X_t = W_t, where phi_x = 2x and phi_xx = 2, giving d(W_t^2) = dt + 2 W_t dW_t.
128
-
129
-
130
- def ito_drift(f_value, g_value, phi_x, phi_xx):
131
- """The dt bracket of Ito's lemma for a probe with no explicit time dependence."""
132
- return f_value * phi_x + 0.5 * g_value ** 2 * phi_xx # Drift term plus curvature correction.
133
-
134
-
135
- def check_ito_on_square(num_paths: int, steps: int, terminal: float, generator=None) -> dict:
136
- """Integrate d(W_t^2) = dt + 2 W_t dW_t on a grid and compare with the exact E[W_t^2] = t."""
137
- h = terminal / steps
138
- w = torch.zeros(num_paths) # [N] Brownian position.
139
- accumulated = torch.zeros(num_paths) # [N] right-hand side of Ito's lemma, integrated.
140
- without_curvature = torch.zeros(num_paths) # [N] the same sum with the dt term dropped.
141
- for _ in range(steps):
142
- dw = brownian_increment(h, (num_paths,), generator) # [N] one increment.
143
- accumulated = accumulated + ito_drift(0.0, 1.0, 2.0 * w, 2.0) * h + 2.0 * w * dw
144
- without_curvature = without_curvature + 2.0 * w * dw # Ordinary chain rule.
145
- w = w + dw # Advance the path itself.
146
- return {
147
- "realized_mean_square": float((w ** 2).mean()), # Direct average of W_T^2.
148
- "ito_mean": float(accumulated.mean()), # Same quantity through Ito's lemma.
149
- "no_curvature_mean": float(without_curvature.mean()), # Near zero, the wrong answer.
150
- "exact_mean_square": terminal, # E[W_T^2] = T.
151
- }
152
-
153
-
154
- # %% 08. The Fokker-Planck equation
155
- #
156
- # d_t p_t = -div(f p_t) + (1/2) g^2 laplacian(p_t). With f = 0 and g = 1 started from N(0, 1), the
157
- # solution stays Gaussian with variance 1 + t, which gives both sides in closed form. Autograd
158
- # supplies the two derivatives, so the identity is checked and not assumed.
159
-
160
-
161
- def spreading_gaussian_log_density(x: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
162
- """log N(x; 0, 1 + t), the exact solution of d_t p = (1/2) d_x^2 p from N(0, 1)."""
163
- variance = 1.0 + t # Variance grows at the rate the diffusion injects it.
164
- return -0.5 * torch.log(2.0 * math.pi * variance) - x ** 2 / (2.0 * variance)
165
-
166
-
167
- def fokker_planck_residual(x: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
168
- """d_t p - (1/2) d_x^2 p on the closed-form solution, which is zero at every (x, t)."""
169
- x = x.clone().requires_grad_(True) # [M] evaluation points.
170
- t = t.clone().requires_grad_(True) # [M] evaluation times.
171
- p = spreading_gaussian_log_density(x, t).exp() # [M] the density itself.
172
- ones = torch.ones_like(p)
173
- dp_dt, dp_dx = torch.autograd.grad(p, (t, x), ones, create_graph=True) # First derivatives.
174
- d2p_dx2, = torch.autograd.grad(dp_dx, x, torch.ones_like(dp_dx)) # Second derivative in x.
175
- return (dp_dt - 0.5 * d2p_dx2).detach() # The equation with f = 0 and g = 1.
176
-
177
-
178
- def peak_extrapolation(step: float) -> dict:
179
- """The chapter's first-order estimate of the peak density after a short interval."""
180
- p_0 = 1.0 / math.sqrt(2.0 * math.pi) # p_0(0), the height of the standard Gaussian.
181
- curvature = -p_0 # p_0''(x) = (x^2 - 1) p_0(x), so p_0''(0) = -p_0(0).
182
- rate = 0.5 * 1.0 * curvature # d_t p_t(0) at t = 0, with g = 1.
183
- linear = p_0 + step * rate # One-step extrapolation from the rate.
184
- exact = 1.0 / math.sqrt(2.0 * math.pi * (1.0 + step)) # Peak of N(0, 1 + step).
185
- return {"p_0": p_0, "rate": rate, "linear": linear, "exact": exact, "gap": exact - linear}
186
-
187
-
188
- # %% 09. The report
189
- def main() -> None:
190
- parser = common_parser(__doc__.split("\n")[0])
191
- parser.set_defaults(steps=400, samples=16384)
192
- args = parser.parse_args()
193
- set_seed(args.seed)
194
- device = pick_device(args.device) # Printed for consistency; this file runs on the CPU.
195
- generator = torch.Generator().manual_seed(args.seed) # Local generator, so the report repeats.
196
-
197
- # The chapter's step: x_k = 2, f = -1, g = 0.5, h = 0.04 and the draw epsilon_k = 1.5.
198
- h = 0.04
199
- landed, drift_part, noise_part = euler_maruyama_step(2.0, -1.0, 0.5, h, 1.5)
200
- scale = increment_scale(h, f_value=-1.0, g_value=0.5)
201
- three_draws = [euler_maruyama_step(2.0, -1.0, 0.5, h, e)[0] for e in (1.5, -0.5, 0.2)]
202
-
203
- # The cloud without a drift: variance g^2 T after the whole interval.
204
- terminal_states = driftless_cloud(args.samples, args.steps, 0.5, TERMINAL_TIME, generator)
205
-
206
- # The Ornstein-Uhlenbeck example: x_0 = 2, kappa = 1, g = sqrt(2), evaluated at t = log 2.
207
- ou_time = math.log(2.0)
208
- ou = ornstein_uhlenbeck_law(2.0, 1.0, math.sqrt(2.0), ou_time)
209
- ou_paths = simulate_ornstein_uhlenbeck(
210
- args.samples, args.steps, 2.0, 1.0, math.sqrt(2.0), ou_time, generator,
211
- )
212
-
213
- ito = check_ito_on_square(args.samples, args.steps, 0.25, generator) # t = 0.25.
214
- grid = torch.linspace(-3.0, 3.0, 61) # [61] evaluation points for the density equation.
215
- residual = fokker_planck_residual(grid, torch.full_like(grid, 0.5)) # At t = 0.5.
216
- peak = peak_extrapolation(0.1)
217
-
218
- report = {
219
- "euler_maruyama x_{k+1}": landed, # 2.11 in the notes.
220
- "drift displacement f h": drift_part, # -0.04.
221
- "noise displacement g sqrt(h) eps": noise_part, # 0.15.
222
- "typical noise over drift": scale["noise_over_drift"], # 2.5 at this step size.
223
- "increment std at h=0.04": scale["std"], # 0.2.
224
- "three endpoints, mean": sum(three_draws) / 3.0, # 2.00 against the drift's 1.96.
225
- "driftless terminal variance": float(terminal_states.var(unbiased=False)), # g^2 T = 0.25.
226
- "OU mean at t=log 2": ou["mean"], # 1, half the initial signal.
227
- "OU variance at t=log 2": ou["variance"], # 0.75 of the stationary 1.
228
- "OU simulated mean": float(ou_paths.mean()),
229
- "OU simulated variance": float(ou_paths.var(unbiased=False)),
230
- "E[W_t^2] at t=0.25, exact": ito["exact_mean_square"], # 0.25.
231
- "E[W_t^2] simulated directly": ito["realized_mean_square"],
232
- "E[W_t^2] through Ito's lemma": ito["ito_mean"],
233
- "E[W_t^2] without the curvature": ito["no_curvature_mean"], # Near zero.
234
- "Fokker-Planck residual, largest": float(residual.abs().max()), # Zero up to float error.
235
- "peak p_0(0)": peak["p_0"], # 0.3989.
236
- "peak rate at t=0": peak["rate"], # -0.1995.
237
- "peak linear estimate at t=0.1": peak["linear"], # 0.3790.
238
- "peak exact at t=0.1": peak["exact"], # 0.3804.
239
- "second-order gap": peak["gap"], # 0.0014.
240
- }
241
- if not args.quiet:
242
- print(f"device={device} steps={args.steps} paths={args.samples} seed={args.seed}")
243
- print_report("Forward corruption without a network", report)
244
- save_run(args.out, config=vars(args), report=report)
245
-
246
-
247
- # %% 10. Run the script
248
- if __name__ == "__main__":
249
- main()