pranamanam commited on
Commit
e416efb
·
verified ·
1 Parent(s): f32fe18

Upload 87 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pranam Chatterjee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
MIGRATION.md ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Replacing the current Hugging Face repository
2
+
3
+ This tree is a rebuild of `ChatterjeeLab/CIS6270` from the final lecture notes. Everything in it is
4
+ new except the four files listed under "keep" below.
5
+
6
+ ## Keep from the current repository
7
+
8
+ | File | Why |
9
+ | --- | --- |
10
+ | `lecture_2/flow_unet_mnist.pt` | The published five-epoch checkpoint. `lecture_2/flow_matching_mnist.py` still defines `FlowUNet` with the same state-dict keys, so the file loads unchanged. Move it to `lecture_2/`. |
11
+ | `lecture_2/mnist_selected_examples.png` | The selected generations in the Lecture 2 guide. |
12
+ | `lecture_2/mnist_selected_trajectories.png` | The noise-to-image trajectories in the same guide. |
13
+ | `LICENSE` | Unchanged, MIT. |
14
+
15
+ The overview figure at the top of the README is hosted on the Hugging Face CDN and is linked, not
16
+ stored, so it carries over on its own.
17
+
18
+ ## Delete
19
+
20
+ Everything else. The old tree held 67 files per lecture folder of which most were generated:
21
+
22
+ - `run.py`, `run_all.py`, `lecture_core.py`, `common.py`, `diffusion.py`, `flows.py`, `examples/`.
23
+ One file per framework replaces the dispatcher, so a method is read in one place.
24
+ - `verified_examples/` in lectures 4 to 7, about 300 JSON files, several over 200 KB. A seeded run
25
+ is now reproduced by running the script, and the numbers a default run gives are in each
26
+ lecture's README.
27
+ - `lecture_6/EQUATION_READINGS.md` (138 KB) and `lecture_7/lecture7_ot_sbm_notes.md` (148 KB). The
28
+ book carries both.
29
+ - `MATHEMATICS.md`, `SOURCES.md`, `SLIDE_CODE_MAP.md`, `source_manifest.json`,
30
+ `paper_manifest.json`, `lecture7_examples.ipynb`. The slide maps pointed at the decks as they
31
+ were before the October rewrites.
32
+ - `lecture_4/data/dna_train.tsv` and `lecture_5/data/dna_train.tsv`, which were byte-identical
33
+ copies of each other. `cis6270.data.synthetic_dna` generates the same data in one place, and
34
+ `cis6270.data.write_dna_tsv` writes it out in the old format for anyone who wants the file.
35
+ - `lecture_3/esm2_example.csv`, `GUIDANCE_NOTES.md`, and the per-lecture `requirements.txt` files.
36
+
37
+ ## What changed for a reader
38
+
39
+ - There is a Lecture 1 now, covering the mathematical foundations chapter.
40
+ - `pip install -e .` puts `cis6270` on the path; otherwise run from the repository root with
41
+ `PYTHONPATH=.`.
42
+ - The shared flags are the same everywhere: `--steps`, `--batch-size`, `--lr`, `--width`,
43
+ `--length`, `--samples`, `--sample-steps`, `--seed`, `--device`, `--out`, `--quiet`.
44
+ - `python -m pytest tests -q` runs 484 tests, which assert the numbers the notes work by hand.
README.md ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CIS 6270 Course Code
2
+
3
+ ![CIS6270_Unified_Generative_Dynamics (1)](https://cdn-uploads.huggingface.co/production/uploads/64cd5b3f0494187a9e8b7c69/myMO3iMdYkquG6D5mhU4S.png)
4
+
5
+ Code for CIS 6270, Discrete Generative Models, at the University of Pennsylvania. One file per
6
+ framework, written to be read beside the lecture notes: the corruption, the loss and the sampler of
7
+ each method are in that file, in the notes' own symbols, with a comment on almost every line.
8
+
9
+ Everything runs offline on a laptop CPU in seconds. The one exception is
10
+ `lecture_2/flow_matching_mnist.py`, which downloads MNIST, as its name says; `--dataset toy` runs it
11
+ offline. Lecture 3 goes the other way: its scripts run offline and reach MNIST or ESM-2 only when
12
+ you ask with `--dataset mnist` or `--dataset esm2`.
13
+
14
+ ```bash
15
+ git clone https://huggingface.co/ChatterjeeLab/CIS6270
16
+ cd CIS6270
17
+ python3 -m venv .venv && source .venv/bin/activate
18
+ python -m pip install -r requirements.txt
19
+
20
+ python lecture_4/mdlm.py # train masked diffusion on DNA and generate, about 20 seconds
21
+ python -m pytest tests -q # the notes' worked numbers, checked against the code
22
+ ```
23
+
24
+ On Windows PowerShell, activate with `.venv\Scripts\Activate.ps1`.
25
+
26
+ ## The lectures
27
+
28
+ | | Lecture | What the code builds | Guide |
29
+ | --- | --- | --- | --- |
30
+ | 1 | Mathematical Foundations | Every worked example of the chapter, printed beside the chapter's own number | [lecture_1](lecture_1/README.md) |
31
+ | 2 | Flow Matching | Velocity fields and the flow map, the conditional path, the objective, MNIST | [lecture_2](lecture_2/README.md) |
32
+ | 3 | Score Matching and Diffusion | The forward SDE, the three score objectives, DDPM, three samplers, guidance, latent diffusion on protein embeddings | [lecture_3](lecture_3/README.md) |
33
+ | 4 | Discrete Diffusion | Jump processes, MDLM, uniform replacement, D3PM, SEDD, block diffusion, guidance, planners, search | [lecture_4](lecture_4/README.md) |
34
+ | 5 | Discrete Flow Matching | The mixture path and its velocity, correctors, kinetic-optimal paths, Edit Flows, Dirichlet, Fisher and Gumbel geometries, guidance, rectification | [lecture_5](lecture_5/README.md) |
35
+ | 6 | Flow Maps | The three axioms, the three residuals, MeanFlow, Shortcut, consistency, the discrete flow maps, stochastic, expanding and posterior maps | [lecture_6](lecture_6/README.md) |
36
+ | 7 | Optimal Transport and Schrödinger Bridges | Kantorovich and duality, Sinkhorn, displacement interpolation, static and dynamic bridges, DSB, DSBM, SF2M, the discrete bridges, branched and entangled | [lecture_7](lecture_7/README.md) |
37
+
38
+ ## How a file is laid out
39
+
40
+ Every script follows the shape of `lecture_2/flow_matching_mnist.py`: a docstring naming the
41
+ framework and the chapter sections it implements, numbered `# %%` sections in the order the lecture
42
+ develops them, and a `main()` that prints a report.
43
+
44
+ ```
45
+ cis6270/ the scaffolding, read once: data, the four small networks, the training loop,
46
+ the sample metrics. None of it is the subject of a lecture.
47
+ lecture_1/ ... 7/ one file per framework, standalone and runnable
48
+ tests/ the chapters' worked numbers, asserted against the code
49
+ ```
50
+
51
+ Nothing in `lecture_N/` imports anything from another `lecture_N/`. To read a method, open its file.
52
+
53
+ ## The data
54
+
55
+ | Data | Used by | Download |
56
+ | --- | --- | --- |
57
+ | Synthetic DNA, four letters, a planted ACGT motif, a GC label | Lectures 4 to 6 | no |
58
+ | Four 2-D Gaussians, two moons, a checkerboard | Lectures 2, 3, 6, 7 | no |
59
+ | Four-token phrases such as `red circle moves left` | Lecture 6 | no |
60
+ | MNIST | Lectures 2, 3, with `--dataset mnist` | yes |
61
+ | ESM-2 residue embeddings | Lecture 3, with `--dataset esm2` | yes |
62
+
63
+ The DNA is synthetic and the two guidance objectives are explicit toy properties. They are there so
64
+ that a training run finishes while you watch it, and so that the structure a sampler is supposed to
65
+ recover is one you can check by eye. Nothing here is a benchmark or a biological predictor.
66
+
67
+ ## The shared flags
68
+
69
+ `--steps`, `--batch-size`, `--lr`, `--width`, `--length`, `--samples`, `--sample-steps`, `--seed`,
70
+ `--device`, `--out`, `--quiet`. Learn them once and they work everywhere. `--out` writes
71
+ `checkpoint.pt`, `config.json`, `report.json`, `losses.json` and `samples.txt`.
72
+
73
+ The defaults are a short budget, enough to see a method work in well under a minute. A method
74
+ usually looks better with `--steps 1500 --width 128`.
75
+
76
+ ## Tests
77
+
78
+ ```bash
79
+ python -m pytest tests -q
80
+ ```
81
+
82
+ The suite asserts the numbers the notes work by hand: the posterior average of the reverse rates on
83
+ one DNA base, the Sinkhorn iterates at `epsilon = 2`, the cocycle and Jacobian of the Gaussian flow,
84
+ the conditional velocity of the mixture path. A number in the book and a number in the code are the
85
+ same number, and the suite is what keeps them that way.
86
+
87
+ ## License
88
+
89
+ [MIT](LICENSE). ESM-2 weights come from their original repository under its own terms.
cis6270/__init__.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared helpers for the CIS 6270 course code.
2
+
3
+ Nothing in this package is the subject of a lecture. The data, the small networks, the training
4
+ loop and the sample metrics live here so that each lecture file holds only the mathematics of its
5
+ own framework. Read this package once; after that every lecture file stands on its own.
6
+ """
7
+
8
+ import warnings
9
+
10
+ # torch 2.14 warns about `torch.jit.script` from inside its own forward-mode autograd machinery,
11
+ # the first time `torch.func.jvp` runs. Chapter 6 uses that function, and nothing here is
12
+ # deprecated, so the message is filtered and the reader never sees it.
13
+ warnings.filterwarnings(
14
+ "ignore", message=r"`torch\.jit\.script` is deprecated", category=FutureWarning,
15
+ )
16
+
17
+ from cis6270 import data, metrics, nets, runner
18
+
19
+ __all__ = ["data", "metrics", "nets", "runner"]
20
+ __version__ = "1.0.0"
cis6270/data.py ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Datasets for the course.
2
+
3
+ Everything here runs offline in under a second, so a fresh clone and the test suite need no
4
+ network. MNIST and ESM-2 embeddings download on first use and are reached only through the
5
+ ``--dataset`` flag of the scripts that offer them.
6
+
7
+ The discrete lectures all use the same synthetic DNA, so a loss that improves in Lecture 4 and a
8
+ loss that improves in Lecture 5 are measured on the same sequences.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ import torch
16
+
17
+ # %% 01. The DNA alphabet
18
+ #
19
+ # Chapters 4 and 5 run every worked example on DNA, so the vocabulary is four letters. The mask
20
+ # token, when a lecture needs one, is appended as index 4 by that lecture, not here, because masked
21
+ # diffusion has a mask and uniform diffusion does not.
22
+
23
+ DNA = "ACGT" # Token 0 is A, 1 is C, 2 is G, 3 is T.
24
+ DNA_SIZE = len(DNA) # K = 4, the alphabet size the chapters call |V|.
25
+ MOTIF = "ACGT" # The planted motif, used as the label in the guidance examples.
26
+
27
+
28
+ def encode_dna(sequences: list[str]) -> torch.Tensor:
29
+ """Turn a list of strings over ACGT into a long tensor of shape [N, L]."""
30
+ index = {letter: i for i, letter in enumerate(DNA)} # Letter to token id.
31
+ rows = [[index[letter] for letter in sequence] for sequence in sequences]
32
+ return torch.tensor(rows, dtype=torch.long) # [N, L] token ids.
33
+
34
+
35
+ def decode_dna(tokens: torch.Tensor) -> list[str]:
36
+ """Turn a long tensor of shape [N, L] back into strings over ACGT."""
37
+ return ["".join(DNA[int(i)] for i in row) for row in tokens] # One string per row.
38
+
39
+
40
+ # %% 02. Synthetic DNA with structure a small model can actually learn
41
+ #
42
+ # A uniform random sequence has nothing to learn, so the sampler would look the same before and
43
+ # after training. These sequences carry two pieces of structure instead: a GC-rich background, and
44
+ # the motif ACGT planted at a random position in a fraction of the sequences. The GC fraction then
45
+ # gives a label for the conditional examples, and the motif gives a property to steer toward.
46
+
47
+
48
+ def synthetic_dna(
49
+ num_sequences: int = 512,
50
+ length: int = 16,
51
+ motif_fraction: float = 0.75,
52
+ gc_bias: float = 0.6,
53
+ seed: int = 0,
54
+ ) -> tuple[torch.Tensor, torch.Tensor]:
55
+ """Draw synthetic DNA with a planted motif.
56
+
57
+ Returns the tokens as a long tensor of shape [N, L] and a binary label of shape [N] that is
58
+ one when the sequence has GC fraction at least 0.6. The label is a deterministic function of
59
+ the sequence, not a measurement of anything biological.
60
+ """
61
+ generator = torch.Generator().manual_seed(seed) # Local generator; global seed untouched.
62
+
63
+ # Background letters: C and G each get gc_bias/2, A and T share the rest.
64
+ background = torch.tensor([
65
+ (1.0 - gc_bias) / 2.0, # A
66
+ gc_bias / 2.0, # C
67
+ gc_bias / 2.0, # G
68
+ (1.0 - gc_bias) / 2.0, # T
69
+ ])
70
+ tokens = torch.multinomial(
71
+ background.expand(num_sequences * length, DNA_SIZE), # One draw per position.
72
+ num_samples=1,
73
+ replacement=True,
74
+ generator=generator,
75
+ ).view(num_sequences, length) # [N, L] background sequence.
76
+
77
+ # Plant the motif in a fraction of the sequences, at a position drawn uniformly.
78
+ motif = encode_dna([MOTIF])[0] # [4] token ids of ACGT.
79
+ carries_motif = torch.rand(num_sequences, generator=generator) < motif_fraction # [N] bool.
80
+ # The motif starts at one of three places, not anywhere, which leaves it a pattern a
81
+ # small model can learn from a few hundred sequences while still varying its position. A start
82
+ # is kept only when the whole motif fits, so a short sequence simply has fewer of them.
83
+ room = length - len(MOTIF) # Last index at which the motif still fits.
84
+ choices = torch.tensor([s for s in (2, 6, 10) if s <= room] or [0])
85
+ starts = choices[torch.randint(0, len(choices), (num_sequences,), generator=generator)]
86
+ for row in torch.nonzero(carries_motif).flatten().tolist():
87
+ start = int(starts[row])
88
+ tokens[row, start:start + len(MOTIF)] = motif # Overwrite those positions.
89
+
90
+ gc = ((tokens == 1) | (tokens == 2)).to(torch.get_default_dtype()).mean(dim=1) # [N] fraction of C and G.
91
+ labels = (gc >= 0.6).long() # [N] the toy label used by the conditional examples.
92
+ return tokens, labels
93
+
94
+
95
+ def dna_splits(
96
+ num_sequences: int = 512, length: int = 16, seed: int = 0, validation: float = 0.2,
97
+ ) -> dict[str, torch.Tensor]:
98
+ """The same synthetic DNA, split into a training and a validation set."""
99
+ tokens, labels = synthetic_dna(num_sequences, length, seed=seed)
100
+ cut = int(num_sequences * (1.0 - validation)) # Index where validation begins.
101
+ return {
102
+ "train_tokens": tokens[:cut], "train_labels": labels[:cut],
103
+ "valid_tokens": tokens[cut:], "valid_labels": labels[cut:],
104
+ }
105
+
106
+
107
+ def write_dna_tsv(path: str | Path, tokens: torch.Tensor, labels: torch.Tensor) -> None:
108
+ """Write sequences and labels as a two-column TSV, the format ``--data`` reads."""
109
+ lines = ["sequence\tlabel"]
110
+ lines += [f"{s}\t{int(y)}" for s, y in zip(decode_dna(tokens), labels)]
111
+ Path(path).write_text("\n".join(lines) + "\n")
112
+
113
+
114
+ def read_dna_tsv(path: str | Path) -> tuple[torch.Tensor, torch.Tensor]:
115
+ """Read a two-column TSV of sequences and labels."""
116
+ rows = Path(path).read_text().strip().split("\n")[1:] # Skip the header line.
117
+ sequences = [row.split("\t")[0] for row in rows]
118
+ labels = [int(row.split("\t")[1]) for row in rows]
119
+ return encode_dna(sequences), torch.tensor(labels, dtype=torch.long)
120
+
121
+
122
+ # %% 03. Two-dimensional densities
123
+ #
124
+ # The continuous lectures need a target we can plot and whose exact density we know. Four Gaussians
125
+ # at the corners of a square give both: the modes are far enough apart that a sampler that collapses
126
+ # is obvious, and the log density is a closed form the reports can quote.
127
+
128
+ GAUSSIAN_CENTERS = torch.tensor([[2.0, 2.0], [2.0, -2.0], [-2.0, 2.0], [-2.0, -2.0]])
129
+ GAUSSIAN_STD = 0.35 # Standard deviation of each component.
130
+
131
+
132
+ def gaussian_mixture_2d(num_points: int = 2048, seed: int = 0) -> torch.Tensor:
133
+ """Draw from four equally weighted Gaussians at the corners of a square. Returns [N, 2]."""
134
+ generator = torch.Generator().manual_seed(seed)
135
+ which = torch.randint(0, len(GAUSSIAN_CENTERS), (num_points,), generator=generator) # [N].
136
+ noise = torch.randn(num_points, 2, generator=generator) * GAUSSIAN_STD # [N, 2].
137
+ return GAUSSIAN_CENTERS[which] + noise # [N, 2] points.
138
+
139
+
140
+ def gaussian_mixture_log_density(points: torch.Tensor) -> torch.Tensor:
141
+ """Exact log density of the four-Gaussian mixture at each row of ``points`` ([N, 2] -> [N])."""
142
+ diff = points[:, None, :] - GAUSSIAN_CENTERS.to(points) # [N, 4, 2] offset to each center.
143
+ squared = (diff ** 2).sum(dim=-1) # [N, 4] squared distance to each center.
144
+ log_component = (
145
+ -0.5 * squared / GAUSSIAN_STD ** 2 # Gaussian exponent.
146
+ - torch.log(torch.tensor(2.0 * torch.pi * GAUSSIAN_STD ** 2)) # Normalizer in 2-D.
147
+ ) # [N, 4].
148
+ weight = torch.log(torch.tensor(1.0 / len(GAUSSIAN_CENTERS))) # Equal mixture weights.
149
+ return torch.logsumexp(log_component + weight, dim=-1) # [N] mixture log density.
150
+
151
+
152
+ def two_moons(num_points: int = 2048, noise: float = 0.08, seed: int = 0) -> torch.Tensor:
153
+ """Two interleaving half circles, the standard test for a sampler that must bend. [N, 2]."""
154
+ generator = torch.Generator().manual_seed(seed)
155
+ half = num_points // 2
156
+ angle = torch.rand(half, generator=generator) * torch.pi # [half] angle along each arc.
157
+ upper = torch.stack([torch.cos(angle), torch.sin(angle)], dim=1) # Upper half circle.
158
+ lower = torch.stack([1.0 - torch.cos(angle), 0.5 - torch.sin(angle)], dim=1) # Lower arc.
159
+ points = torch.cat([upper, lower], dim=0) # [2*half, 2].
160
+ points = points + noise * torch.randn(points.shape, generator=generator) # Blur the arcs.
161
+ return (points - points.mean(0)) / points.std(0) # Standardize to roughly unit scale.
162
+
163
+
164
+ def checkerboard(num_points: int = 2048, seed: int = 0) -> torch.Tensor:
165
+ """A checkerboard of eight squares, a density with disconnected support. [N, 2]."""
166
+ generator = torch.Generator().manual_seed(seed)
167
+ x = torch.rand(num_points, generator=generator) * 4.0 - 2.0 # [N] in [-2, 2].
168
+ y = torch.rand(num_points, generator=generator) - torch.randint(
169
+ 0, 2, (num_points,), generator=generator,
170
+ ).to(torch.get_default_dtype()) * 2.0 # [N] in [-2, 1] before the shift below.
171
+ y = y + torch.floor(x) % 2.0 # Shift alternate columns so the squares interleave.
172
+ return torch.stack([x, y * 2.0], dim=1) # [N, 2] points.
173
+
174
+
175
+ # %% 04. Short phrases, for the discrete examples whose tokens are words
176
+ #
177
+ # Chapter 6 runs its categorical flow maps on four-token phrases. The grammar is small enough that
178
+ # a reader can tell at a glance whether a generated phrase is in the language.
179
+
180
+ _SUBJECTS = ["red", "blue", "green", "small"]
181
+ _OBJECTS = ["circle", "square", "arrow", "dot"]
182
+ _VERBS = ["moves", "turns", "drifts", "jumps"]
183
+ _DIRECTIONS = ["left", "right", "up", "down"]
184
+
185
+
186
+ def phrase_vocabulary() -> list[str]:
187
+ """The token vocabulary of the phrase language, in a fixed order."""
188
+ return _SUBJECTS + _OBJECTS + _VERBS + _DIRECTIONS # 16 tokens, four per slot.
189
+
190
+
191
+ def phrases(num_phrases: int = 512, seed: int = 0) -> tuple[torch.Tensor, list[str]]:
192
+ """Draw four-token phrases such as ``red circle moves left``. Returns [N, 4] and the vocabulary.
193
+
194
+ Each slot draws from its own quarter of the vocabulary, so a correct phrase is one whose
195
+ position i holds a token from group i. That makes the training support easy to check.
196
+ """
197
+ generator = torch.Generator().manual_seed(seed)
198
+ vocabulary = phrase_vocabulary()
199
+ groups = [_SUBJECTS, _OBJECTS, _VERBS, _DIRECTIONS]
200
+ columns = []
201
+ for slot, group in enumerate(groups):
202
+ offset = slot * len(group) # Where this slot's tokens start in the vocabulary.
203
+ picks = torch.randint(0, len(group), (num_phrases,), generator=generator)
204
+ columns.append(picks + offset) # [N] token ids inside the full vocabulary.
205
+ return torch.stack(columns, dim=1), vocabulary # [N, 4] and the 16 token strings.
206
+
207
+
208
+ # %% 05. MNIST and ESM-2, the two datasets that download
209
+ #
210
+ # Both are optional. A script reaches them only when the reader passes --dataset mnist or
211
+ # --dataset esm2, and every such script also runs on the offline data above.
212
+
213
+
214
+ def mnist_loader(batch_size: int = 128, root: str | Path = "data", train: bool = True):
215
+ """MNIST as a DataLoader of images normalized to [-1, 1]. Downloads on first use."""
216
+ from torch.utils.data import DataLoader # Imported here so the module loads without torchvision.
217
+ from torchvision import datasets
218
+ from torchvision.transforms import v2
219
+
220
+ transform = v2.Compose([
221
+ v2.ToImage(), # Channel-first image: [1, 28, 28].
222
+ v2.ToDtype(torch.float32, scale=True), # Integer pixels 0-255 to floats in [0, 1].
223
+ v2.Normalize(mean=(0.5,), std=(0.5,)), # Then [0, 1] to [-1, 1].
224
+ ])
225
+ dataset = datasets.MNIST(root=root, train=train, download=True, transform=transform)
226
+ return DataLoader(dataset, batch_size=batch_size, shuffle=train, num_workers=0)
227
+
228
+
229
+ def toy_images(num_images: int = 256, size: int = 28, seed: int = 0) -> torch.Tensor:
230
+ """Images shaped like MNIST, drawn from a two-mode distribution of bars and blobs.
231
+
232
+ These stand in for MNIST wherever a test has to run without a download. They have the same
233
+ shape and the same [-1, 1] range, so the identical training and sampling code paths run.
234
+ """
235
+ generator = torch.Generator().manual_seed(seed)
236
+ images = torch.full((num_images, 1, size, size), -1.0) # Background at the low end of [-1, 1].
237
+ grid = torch.arange(size)
238
+ for i in range(num_images):
239
+ if i % 2 == 0: # A vertical bar.
240
+ column = int(torch.randint(6, size - 6, (1,), generator=generator))
241
+ images[i, 0, 4:size - 4, column - 2:column + 2] = 1.0
242
+ else: # A round blob.
243
+ cy = int(torch.randint(8, size - 8, (1,), generator=generator))
244
+ cx = int(torch.randint(8, size - 8, (1,), generator=generator))
245
+ dy = (grid[:, None] - cy) ** 2
246
+ dx = (grid[None, :] - cx) ** 2
247
+ images[i, 0][(dy + dx) <= 25] = 1.0
248
+ images = images + 0.05 * torch.randn(images.shape, generator=generator) # Light pixel noise.
249
+ return images.clamp(-1.0, 1.0) # [N, 1, size, size] in the training range.
250
+
251
+
252
+ PEPTIDES = [
253
+ "KWKLFKKIGAVLKVL", "GIGKFLHSAKKFGKA", "FLPIIAKLLSGLL", "ILPWKWPWWPWRR",
254
+ "KRWWKWWRR", "GLLSKLWKKVFKAFKKFLKK", "ACDEFGHIKLMNPQR", "RRWWRRWRR",
255
+ "LLGDFFRKSKEKIGKE", "KIAKVALKAL", "VRRFPWWWPFLRR", "GWLKKIGKKIERVGQ",
256
+ ] # Short antimicrobial-peptide-like sequences, used when ESM-2 is unavailable.
257
+
258
+
259
+ def esm2_embeddings(
260
+ sequences: list[str] | None = None,
261
+ model_name: str = "facebook/esm2_t33_650M_UR50D",
262
+ max_length: int = 32,
263
+ device: torch.device | str = "cpu",
264
+ ):
265
+ """Residue embeddings from a frozen ESM-2 encoder. Downloads the checkpoint on first use.
266
+
267
+ Returns the embeddings [N, L, D], the attention mask [N, L], the tokenizer and the masked
268
+ language-model head, which Lecture 3 uses to decode generated latents back to amino acids.
269
+ """
270
+ from transformers import AutoModel, AutoModelForMaskedLM, AutoTokenizer
271
+
272
+ sequences = sequences or PEPTIDES
273
+ tokenizer = AutoTokenizer.from_pretrained(model_name)
274
+ encoder = AutoModel.from_pretrained(model_name).to(device).eval()
275
+ head = AutoModelForMaskedLM.from_pretrained(model_name).lm_head.to(device).eval()
276
+ for parameter in encoder.parameters():
277
+ parameter.requires_grad = False # The encoder is frozen; only the denoiser trains.
278
+
279
+ batch = tokenizer(
280
+ sequences, padding="max_length", truncation=True,
281
+ max_length=max_length, return_tensors="pt",
282
+ ).to(device)
283
+ with torch.no_grad():
284
+ embeddings = encoder(**batch).last_hidden_state # [N, L, D] residue embeddings.
285
+ return embeddings, batch["attention_mask"], tokenizer, head
286
+
287
+
288
+ def toy_embeddings(
289
+ num_sequences: int = 128, length: int = 16, width: int = 32, seed: int = 0,
290
+ ) -> tuple[torch.Tensor, torch.Tensor]:
291
+ """Embeddings shaped like ESM-2 output, for the offline path of the latent examples.
292
+
293
+ Each sequence is a smooth low-frequency curve along the residue axis plus noise, so neighbouring
294
+ positions are correlated the way real residue embeddings are. Returns [N, L, D] and a mask.
295
+ """
296
+ generator = torch.Generator().manual_seed(seed)
297
+ phase = torch.rand(num_sequences, 1, width, generator=generator) * 2.0 * torch.pi
298
+ frequency = torch.rand(num_sequences, 1, width, generator=generator) * 0.6 + 0.1
299
+ position = torch.arange(length).view(1, length, 1).to(torch.get_default_dtype()) # [1, L, 1].
300
+ signal = torch.sin(frequency * position + phase) # [N, L, D] smooth along the residue axis.
301
+ noise = 0.2 * torch.randn(num_sequences, length, width, generator=generator)
302
+ mask = torch.ones(num_sequences, length) # No padding in the toy set.
303
+ return signal + noise, mask
304
+
305
+
306
+ def standardize(embeddings: torch.Tensor, mask: torch.Tensor):
307
+ """Center and scale each embedding coordinate over the valid residues.
308
+
309
+ Returns the standardized embeddings and the mean and standard deviation, which the decoder
310
+ needs in order to undo the transformation before reading amino acids.
311
+ """
312
+ valid = mask.bool().unsqueeze(-1) # [N, L, 1] true at a real residue.
313
+ count = valid.sum() # Number of valid residue-coordinate entries.
314
+ mean = (embeddings * valid).sum(dim=(0, 1)) / count # [D] per-coordinate mean.
315
+ variance = ((embeddings - mean) ** 2 * valid).sum(dim=(0, 1)) / count # [D].
316
+ std = variance.sqrt().clamp(min=1e-6) # [D], clamped so a dead coordinate cannot divide by zero.
317
+ return (embeddings - mean) / std, mean, std
cis6270/metrics.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Measurements of a batch of generated samples.
2
+
3
+ None of these is a benchmark. They are small, exactly computable quantities that tell a reader
4
+ whether a sampler learned the structure planted in the data: the right letter frequencies, the
5
+ planted motif, the right number of modes. A number here is a diagnostic, not a score.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import torch
11
+
12
+ from cis6270.data import GAUSSIAN_CENTERS, MOTIF, decode_dna
13
+
14
+
15
+ # %% 01. Measurements on DNA
16
+
17
+
18
+ def gc_fraction(tokens: torch.Tensor) -> float:
19
+ """Fraction of positions holding C or G, averaged over the batch. The training data sit at 0.577."""
20
+ return float(((tokens == 1) | (tokens == 2)).to(torch.get_default_dtype()).mean())
21
+
22
+
23
+ def motif_fraction(tokens: torch.Tensor, motif: str = MOTIF) -> float:
24
+ """Fraction of sequences that contain the planted motif anywhere."""
25
+ sequences = decode_dna(tokens)
26
+ return sum(motif in sequence for sequence in sequences) / max(len(sequences), 1)
27
+
28
+
29
+ def unique_fraction(tokens: torch.Tensor) -> float:
30
+ """Fraction of generated sequences that are distinct, which catches a collapsed sampler.
31
+
32
+ The comparison is on the token ids themselves, so this works for any vocabulary, not only the
33
+ four DNA letters.
34
+ """
35
+ rows = [tuple(row.tolist()) for row in tokens] # One tuple per sequence.
36
+ return len(set(rows)) / max(len(rows), 1)
37
+
38
+
39
+ def token_entropy(tokens: torch.Tensor, vocab_size: int) -> float:
40
+ """Entropy in nats of the letter frequencies, at most log K. Four letters give log 4 = 1.386."""
41
+ counts = torch.bincount(tokens.flatten(), minlength=vocab_size).to(torch.get_default_dtype())
42
+ probabilities = counts / counts.sum().clamp(min=1)
43
+ nonzero = probabilities[probabilities > 0]
44
+ return float(-(nonzero * nonzero.log()).sum())
45
+
46
+
47
+ def support_fraction(tokens: torch.Tensor, training: torch.Tensor) -> float:
48
+ """Fraction of generated sequences that also appear in the training set."""
49
+ seen = {tuple(row.tolist()) for row in training}
50
+ return sum(tuple(row.tolist()) in seen for row in tokens) / max(len(tokens), 1)
51
+
52
+
53
+ # %% 02. Measurements on two-dimensional samples
54
+
55
+
56
+ def mode_report(points: torch.Tensor, centers: torch.Tensor | None = None) -> dict:
57
+ """Assign each point to its nearest mode and report the distance and the coverage.
58
+
59
+ ``nearest_distance`` near the data's own standard deviation means the points land on the modes.
60
+ ``mode_entropy`` near log 4 = 1.386 means all four modes are visited about equally.
61
+ """
62
+ centers = GAUSSIAN_CENTERS.to(points) if centers is None else centers.to(points)
63
+ distance = torch.cdist(points, centers) # [N, M] distance to every center.
64
+ nearest, which = distance.min(dim=1) # [N] distance and index of the closest mode.
65
+ counts = torch.bincount(which, minlength=len(centers)).to(torch.get_default_dtype())
66
+ fractions = counts / counts.sum().clamp(min=1)
67
+ nonzero = fractions[fractions > 0]
68
+ return {
69
+ "nearest_distance": float(nearest.mean()),
70
+ "modes_found": int((counts > 0).sum()),
71
+ "mode_entropy": float(-(nonzero * nonzero.log()).sum()),
72
+ "mode_fractions": [round(float(f), 3) for f in fractions],
73
+ }
74
+
75
+
76
+ def mean_log_density(points: torch.Tensor) -> float:
77
+ """Average exact log density of the four-Gaussian target at the generated points."""
78
+ from cis6270.data import gaussian_mixture_log_density
79
+
80
+ return float(gaussian_mixture_log_density(points).mean())
81
+
82
+
83
+ # %% 03. Measurements on a learned distribution over a small state space
84
+ #
85
+ # Several chapters work on a state space small enough to enumerate, where the exact answer is
86
+ # available. Comparing an empirical histogram with that exact answer is the sharpest check the
87
+ # course has.
88
+
89
+
90
+ def total_variation(p: torch.Tensor, q: torch.Tensor) -> float:
91
+ """Half the sum of absolute differences between two distributions over the same states."""
92
+ return float(0.5 * (p - q).abs().sum())
93
+
94
+
95
+ def empirical_distribution(tokens: torch.Tensor, vocab_size: int, length: int) -> torch.Tensor:
96
+ """Histogram of whole sequences over the K**L states, for a state space small enough to list."""
97
+ weights = vocab_size ** torch.arange(length - 1, -1, -1, device=tokens.device) # Place values.
98
+ indices = (tokens * weights).sum(dim=1) # [N] index of each sequence in the full state space.
99
+ counts = torch.bincount(indices, minlength=vocab_size ** length).to(torch.get_default_dtype())
100
+ return counts / counts.sum().clamp(min=1) # [K**L] empirical distribution.
cis6270/nets.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The small networks every lecture reuses.
2
+
3
+ Four networks cover the whole course. None of them is the subject of a lecture: a lecture changes
4
+ the loss and the sampler, and keeps the network fixed, so that any difference in the samples comes
5
+ from the mathematics and not from the architecture.
6
+
7
+ | Network | Input | Output | Used by |
8
+ | ------------------ | -------------------------- | --------------------- | ------- |
9
+ | `TimeEmbedding` | a time or step index | a vector of width D | all |
10
+ | `SequenceDenoiser` | tokens [B, L] and a time | logits [B, L, K] | 4, 5, 6 |
11
+ | `VelocityMLP` | a point [B, D] and a time | a vector [B, D] | 2, 3, 6, 7 |
12
+ | `TwoTimeMLP` | a point, a start, an end | a vector [B, D] | 6 |
13
+ | `SmallUNet` | an image [B, 1, 28, 28] | an image of the same | 2, 3 |
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+
20
+ import torch
21
+ import torch.nn as nn
22
+
23
+
24
+ # %% 01. Embedding a continuous time
25
+ #
26
+ # Every network in the course takes a time as well as a state. A raw scalar in [0, 1] is a poor
27
+ # input to a linear layer, so it is first spread over many frequencies, exactly as in Section 3.28
28
+ # of the notes, and then passed through a small MLP.
29
+
30
+
31
+ def sinusoidal_embedding(t: torch.Tensor, width: int) -> torch.Tensor:
32
+ """Spread a time [B] over ``width`` sine and cosine frequencies. Returns [B, width]."""
33
+ half = width // 2 # Half the coordinates are sines and half cosines.
34
+ frequencies = torch.exp(
35
+ -math.log(10000.0) * torch.arange(half, device=t.device).to(torch.get_default_dtype()) / half
36
+ ) # [half] geometrically spaced frequencies.
37
+ angles = t.to(torch.get_default_dtype())[:, None] * frequencies[None, :] # [B, half].
38
+ return torch.cat([torch.sin(angles), torch.cos(angles)], dim=1) # [B, width].
39
+
40
+
41
+ class TimeEmbedding(nn.Module):
42
+ """Sinusoidal features of the time, followed by a two-layer MLP."""
43
+
44
+ def __init__(self, width: int = 128, scale: float = 1000.0):
45
+ super().__init__()
46
+ self.width = width # Width of the embedding the rest of the network receives.
47
+ self.scale = scale # Times in [0, 1] are multiplied by this before the sines.
48
+ self.mlp = nn.Sequential(
49
+ nn.Linear(width, width), nn.SiLU(), nn.Linear(width, width),
50
+ )
51
+
52
+ def forward(self, t: torch.Tensor) -> torch.Tensor:
53
+ """t: [B] -> [B, width]."""
54
+ return self.mlp(sinusoidal_embedding(t * self.scale, self.width)) # [B, width].
55
+
56
+
57
+ # %% 02. A denoiser over token sequences
58
+ #
59
+ # Lectures 4, 5 and 6 all train the same shape of network: it reads a corrupted sequence and a time,
60
+ # and produces a distribution over the vocabulary at every position. The notes call it the denoiser
61
+ # d_theta(. | z, t). Attention lets the prediction at one position depend on every other position,
62
+ # which is what makes the factorized reverse step of Chapter 4 worth taking.
63
+
64
+
65
+ class SequenceDenoiser(nn.Module):
66
+ """A small transformer that maps a token sequence and a time to per-position logits."""
67
+
68
+ def __init__(
69
+ self,
70
+ vocab_size: int,
71
+ length: int,
72
+ width: int = 128,
73
+ depth: int = 2,
74
+ heads: int = 4,
75
+ num_classes: int = 0,
76
+ ):
77
+ super().__init__()
78
+ self.vocab_size = vocab_size # K, including a mask token when the lecture adds one.
79
+ self.length = length # L, the number of positions.
80
+ self.width = width # D, the model width.
81
+ self.token_embedding = nn.Embedding(vocab_size, width) # One vector per token id.
82
+ self.position_embedding = nn.Embedding(length, width) # One vector per position.
83
+ self.time_embedding = TimeEmbedding(width) # Shared across positions.
84
+ # A label embedding, used only by the conditional examples. Index num_classes is the null
85
+ # label that classifier-free guidance drops to.
86
+ self.label_embedding = (
87
+ nn.Embedding(num_classes + 1, width) if num_classes else None
88
+ )
89
+ layer = nn.TransformerEncoderLayer(
90
+ d_model=width, nhead=heads, dim_feedforward=4 * width,
91
+ batch_first=True, dropout=0.0, norm_first=True,
92
+ )
93
+ self.transformer = nn.TransformerEncoder(
94
+ layer, num_layers=depth, enable_nested_tensor=False,
95
+ )
96
+ self.head = nn.Sequential(nn.LayerNorm(width), nn.Linear(width, vocab_size))
97
+
98
+ def forward(
99
+ self, tokens: torch.Tensor, t: torch.Tensor, labels: torch.Tensor | None = None,
100
+ ) -> torch.Tensor:
101
+ """tokens: [B, L], t: [B], labels: [B] or None -> logits [B, L, K]."""
102
+ positions = torch.arange(tokens.shape[1], device=tokens.device) # [L].
103
+ h = (
104
+ self.token_embedding(tokens) # [B, L, D] what each position currently holds.
105
+ + self.position_embedding(positions)[None, :, :] # [1, L, D] where it sits.
106
+ + self.time_embedding(t)[:, None, :] # [B, 1, D] how far along the path we are.
107
+ )
108
+ if labels is not None and self.label_embedding is not None:
109
+ h = h + self.label_embedding(labels)[:, None, :] # [B, 1, D] the condition.
110
+ h = self.transformer(h) # [B, L, D] every position now sees every other.
111
+ return self.head(h) # [B, L, K] logits over the vocabulary at each position.
112
+
113
+
114
+ # %% 03. A velocity network on a vector state
115
+ #
116
+ # Lectures 2, 3, 6 and 7 learn a field on R^d: a velocity, a score, a noise prediction or a drift.
117
+ # The same MLP serves for all of them, because the only difference is what the loss regresses it
118
+ # onto.
119
+
120
+
121
+ class VelocityMLP(nn.Module):
122
+ """An MLP from a point and a time to a vector of the same width as the point."""
123
+
124
+ def __init__(self, dim: int = 2, width: int = 128, depth: int = 3, num_classes: int = 0):
125
+ super().__init__()
126
+ self.dim = dim # d, the width of the state.
127
+ self.time_embedding = TimeEmbedding(width, scale=1.0) # Times here already live in [0, 1].
128
+ self.label_embedding = (
129
+ nn.Embedding(num_classes + 1, width) if num_classes else None
130
+ )
131
+ self.input = nn.Linear(dim, width) # Lift the state to the hidden width.
132
+ layers: list[nn.Module] = []
133
+ for _ in range(depth):
134
+ layers += [nn.Linear(width, width), nn.SiLU()]
135
+ self.body = nn.Sequential(*layers)
136
+ self.output = nn.Linear(width, dim) # Back down to one vector per coordinate.
137
+
138
+ def forward(
139
+ self, x: torch.Tensor, t: torch.Tensor, labels: torch.Tensor | None = None,
140
+ ) -> torch.Tensor:
141
+ """x: [B, d], t: [B], labels: [B] or None -> [B, d]."""
142
+ h = self.input(x) + self.time_embedding(t) # [B, width] state and time together.
143
+ if labels is not None and self.label_embedding is not None:
144
+ h = h + self.label_embedding(labels) # [B, width] the condition.
145
+ return self.output(self.body(h)) # [B, d].
146
+
147
+
148
+ # %% 04. A two-time network, for the flow maps of Lecture 6
149
+ #
150
+ # A flow map carries a state from a start time s to an end time t, so its network takes both. The
151
+ # identity axiom X_{s,s} = id is free if the map is written as the state plus the elapsed time times
152
+ # a network output, which is what `flow_map` below does.
153
+
154
+
155
+ class TwoTimeMLP(nn.Module):
156
+ """An MLP from a point, a start time and an end time to a vector."""
157
+
158
+ def __init__(self, dim: int = 2, width: int = 128, depth: int = 3):
159
+ super().__init__()
160
+ self.dim = dim
161
+ self.start_embedding = TimeEmbedding(width, scale=1.0) # Embedding of s.
162
+ self.end_embedding = TimeEmbedding(width, scale=1.0) # Embedding of t.
163
+ self.input = nn.Linear(dim, width)
164
+ layers: list[nn.Module] = []
165
+ for _ in range(depth):
166
+ layers += [nn.Linear(width, width), nn.SiLU()]
167
+ self.body = nn.Sequential(*layers)
168
+ self.output = nn.Linear(width, dim)
169
+
170
+ def forward(self, x: torch.Tensor, s: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
171
+ """The average velocity v_{s,t}(x). x: [B, d], s: [B], t: [B] -> [B, d]."""
172
+ h = self.input(x) + self.start_embedding(s) + self.end_embedding(t) # [B, width].
173
+ return self.output(self.body(h)) # [B, d].
174
+
175
+ def flow_map(self, x: torch.Tensor, s: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
176
+ """X_{s,t}(x) = x + (t - s) v_{s,t}(x), so the identity axiom holds by construction."""
177
+ elapsed = (t - s)[:, None] # [B, 1] elapsed time, broadcast over coordinates.
178
+ return x + elapsed * self.forward(x, s, t) # [B, d].
179
+
180
+
181
+ # %% 05. A small U-Net for 28-by-28 images
182
+ #
183
+ # Lectures 2 and 3 generate images. The network is deliberately small, about half a million
184
+ # parameters, so that a few epochs on a laptop CPU already produce recognizable digits.
185
+
186
+
187
+ def _conv_block(in_channels: int, out_channels: int) -> nn.Sequential:
188
+ """Two 3-by-3 convolutions with a SiLU after each, keeping the height and width."""
189
+ return nn.Sequential(
190
+ nn.Conv2d(in_channels, out_channels, 3, padding=1), nn.SiLU(),
191
+ nn.Conv2d(out_channels, out_channels, 3, padding=1), nn.SiLU(),
192
+ )
193
+
194
+
195
+ class SmallUNet(nn.Module):
196
+ """A three-scale U-Net that maps an image and a time to an image of the same shape.
197
+
198
+ The time enters as an extra constant channel, which is the simplest conditioning that works and
199
+ is what the Lecture 2 script uses.
200
+ """
201
+
202
+ def __init__(self, base_channels: int = 32, in_channels: int = 1):
203
+ super().__init__()
204
+ c = base_channels # Widths are c, 2c, 4c.
205
+ self.encoder1 = _conv_block(in_channels + 1, c) # Image plus the time channel.
206
+ self.encoder2 = _conv_block(c, 2 * c)
207
+ self.middle = _conv_block(2 * c, 4 * c)
208
+ self.decoder2 = _conv_block(6 * c, 2 * c) # Upsampled 4c joined with the 2c skip.
209
+ self.decoder1 = _conv_block(3 * c, c) # Upsampled 2c joined with the c skip.
210
+ self.output = nn.Conv2d(c, in_channels, 1) # One prediction per pixel.
211
+ self.pool = nn.MaxPool2d(2) # Halve the height and width.
212
+
213
+ def forward(self, x: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
214
+ """x: [B, 1, 28, 28], t: [B] -> [B, 1, 28, 28]."""
215
+ import torch.nn.functional as F # Local import keeps the module header short.
216
+
217
+ time_channel = t[:, None, None, None].expand(x.shape[0], 1, *x.shape[2:]) # [B, 1, H, W].
218
+ h = torch.cat([x, time_channel], dim=1) # [B, 2, H, W].
219
+ skip1 = self.encoder1(h) # [B, c, 28, 28].
220
+ skip2 = self.encoder2(self.pool(skip1)) # [B, 2c, 14, 14].
221
+ h = self.middle(self.pool(skip2)) # [B, 4c, 7, 7] the compressed representation.
222
+ h = F.interpolate(h, size=skip2.shape[-2:], mode="nearest") # 7 to 14.
223
+ h = self.decoder2(torch.cat([h, skip2], dim=1)) # Rejoin the 14-by-14 detail.
224
+ h = F.interpolate(h, size=skip1.shape[-2:], mode="nearest") # 14 to 28.
225
+ h = self.decoder1(torch.cat([h, skip1], dim=1)) # Rejoin the 28-by-28 detail.
226
+ return self.output(h) # [B, 1, 28, 28].
227
+
228
+
229
+ def count_parameters(model: nn.Module) -> int:
230
+ """Number of trainable weights and biases, printed by every script at startup."""
231
+ return sum(p.numel() for p in model.parameters() if p.requires_grad)
cis6270/runner.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Seeding, device selection, the training loop, checkpoints and the shared command line.
2
+
3
+ Every lecture script ends in the same four lines: parse the arguments, build the model, call
4
+ ``train`` with a step function, and print a report. The step function is the only part that differs
5
+ between frameworks, and it is the part the lecture is about.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import random
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Callable
16
+
17
+ import torch
18
+
19
+
20
+ # %% 01. Reproducibility and hardware
21
+
22
+
23
+ def set_seed(seed: int) -> None:
24
+ """Seed Python, NumPy through torch, and torch itself, so a run repeats exactly."""
25
+ random.seed(seed)
26
+ torch.manual_seed(seed)
27
+ if torch.cuda.is_available():
28
+ torch.cuda.manual_seed_all(seed)
29
+
30
+
31
+ def pick_device(requested: str = "auto") -> torch.device:
32
+ """Choose CUDA, then Apple MPS, then CPU, unless the reader names one."""
33
+ if requested != "auto":
34
+ return torch.device(requested)
35
+ if torch.cuda.is_available():
36
+ return torch.device("cuda")
37
+ if torch.backends.mps.is_available():
38
+ return torch.device("mps")
39
+ return torch.device("cpu")
40
+
41
+
42
+ # %% 02. The command line every script shares
43
+ #
44
+ # A reader who has learned the flags once knows them for all fifty scripts. A script adds its own
45
+ # flags on top of these with `parser.add_argument`.
46
+
47
+
48
+ def common_parser(description: str) -> argparse.ArgumentParser:
49
+ """The flags shared by every script in the course."""
50
+ parser = argparse.ArgumentParser(
51
+ description=description, formatter_class=argparse.ArgumentDefaultsHelpFormatter,
52
+ )
53
+ parser.add_argument("--steps", type=int, default=600, help="optimizer steps")
54
+ parser.add_argument("--batch-size", type=int, default=64, help="examples per step")
55
+ parser.add_argument("--lr", type=float, default=1e-3, help="learning rate")
56
+ parser.add_argument("--width", type=int, default=128, help="hidden width of the network")
57
+ parser.add_argument("--length", type=int, default=16, help="sequence length L")
58
+ parser.add_argument("--samples", type=int, default=16, help="sequences or points to generate")
59
+ parser.add_argument("--sample-steps", type=int, default=64, help="integration steps")
60
+ parser.add_argument("--seed", type=int, default=0, help="random seed")
61
+ parser.add_argument("--device", type=str, default="auto", help="auto, cpu, cuda or mps")
62
+ parser.add_argument("--out", type=str, default="", help="directory for the run's output")
63
+ parser.add_argument("--quiet", action="store_true", help="print only the final report")
64
+ return parser
65
+
66
+
67
+ # %% 03. The training loop
68
+ #
69
+ # One loop for the whole course. The caller passes a function that takes the step index and returns
70
+ # a scalar loss with a graph attached; everything else, the optimizer, the logging and the timing,
71
+ # is the same in every lecture.
72
+
73
+
74
+ def train(
75
+ model: torch.nn.Module,
76
+ loss_fn: Callable[[int], torch.Tensor],
77
+ steps: int = 600,
78
+ lr: float = 1e-3,
79
+ log_every: int = 100,
80
+ quiet: bool = False,
81
+ grad_clip: float = 1.0,
82
+ extra_parameters: list[torch.nn.Parameter] | None = None,
83
+ ) -> list[float]:
84
+ """Run ``steps`` optimizer steps on ``loss_fn`` and return the loss at every step.
85
+
86
+ ``loss_fn(step)`` draws its own batch, so a lecture that needs fresh noise at every step gets it
87
+ without a DataLoader. ``extra_parameters`` covers the rare case of a second trained object, such
88
+ as the bias force of Chapter 7.
89
+ """
90
+ parameters = list(model.parameters()) + list(extra_parameters or [])
91
+ optimizer = torch.optim.AdamW(parameters, lr=lr, weight_decay=0.0)
92
+ losses: list[float] = []
93
+ started = time.time()
94
+ model.train()
95
+ for step in range(steps):
96
+ loss = loss_fn(step) # The lecture's own objective.
97
+ optimizer.zero_grad(set_to_none=True)
98
+ loss.backward()
99
+ torch.nn.utils.clip_grad_norm_(parameters, grad_clip) # Keep a bad batch from derailing.
100
+ optimizer.step()
101
+ losses.append(float(loss.detach()))
102
+ if not quiet and log_every and (step + 1) % log_every == 0:
103
+ window = losses[-log_every:] # Average over the last window, which is less jumpy.
104
+ print(f" step {step + 1:5d}/{steps} loss {sum(window) / len(window):.4f}")
105
+ if not quiet:
106
+ print(f" trained {steps} steps in {time.time() - started:.1f}s")
107
+ model.eval()
108
+ return losses
109
+
110
+
111
+ # %% 04. Saving a run
112
+ #
113
+ # A run writes three files: the weights, the configuration that produced them, and a short report of
114
+ # what came out. No optimizer state, because none of these examples resumes training.
115
+
116
+
117
+ def save_run(
118
+ out: str | Path,
119
+ model: torch.nn.Module | None = None,
120
+ config: dict | None = None,
121
+ report: dict | None = None,
122
+ losses: list[float] | None = None,
123
+ samples: list[str] | None = None,
124
+ ) -> Path | None:
125
+ """Write the weights, the configuration, the report, the loss curve and any samples."""
126
+ if not out:
127
+ return None
128
+ directory = Path(out)
129
+ directory.mkdir(parents=True, exist_ok=True)
130
+ if model is not None:
131
+ torch.save({"model": model.state_dict(), "config": config or {}},
132
+ directory / "checkpoint.pt")
133
+ if config is not None:
134
+ (directory / "config.json").write_text(json.dumps(config, indent=2) + "\n")
135
+ if report is not None:
136
+ (directory / "report.json").write_text(json.dumps(report, indent=2) + "\n")
137
+ if losses is not None:
138
+ # Thin the curve to at most 200 points so the file stays small and readable.
139
+ stride = max(1, len(losses) // 200)
140
+ thinned = [round(v, 5) for v in losses[::stride]]
141
+ (directory / "losses.json").write_text(json.dumps(thinned) + "\n")
142
+ if samples is not None:
143
+ (directory / "samples.txt").write_text("\n".join(samples) + "\n")
144
+ return directory
145
+
146
+
147
+ def load_run(out: str | Path, model: torch.nn.Module) -> dict:
148
+ """Read the weights back into ``model`` and return the configuration that trained them."""
149
+ payload = torch.load(Path(out) / "checkpoint.pt", map_location="cpu", weights_only=False)
150
+ model.load_state_dict(payload["model"])
151
+ model.eval()
152
+ return payload.get("config", {})
153
+
154
+
155
+ # %% 05. Printing a report
156
+ #
157
+ # Every script ends with the same two-column block, so a reader can compare two methods by reading
158
+ # two reports side by side.
159
+
160
+
161
+ def print_report(title: str, report: dict, samples: list[str] | None = None) -> None:
162
+ """Print a titled block of name and value pairs, and a few samples."""
163
+ print(f"\n{title}")
164
+ print("-" * len(title))
165
+ for key, value in report.items():
166
+ if isinstance(value, float):
167
+ print(f" {key:<34s} {value:.4f}")
168
+ else:
169
+ print(f" {key:<34s} {value}")
170
+ if samples:
171
+ print("\n samples")
172
+ for sample in samples[:8]:
173
+ print(f" {sample}")
lecture_1/README.md ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Lecture 1: Introduction and Mathematical Foundations
2
+
3
+ Chapter 1 of the notes is the toolbox the other six chapters draw on, and it works every piece of
4
+ it on a small example by hand. The six scripts here run those examples. Each one prints the
5
+ chapter's hand-worked value, the value the script computes for it, and the gap between the two, so
6
+ a student reading the chapter can watch its arithmetic come out of the code. Nothing here trains a
7
+ network. The scripts are worked examples, and `tests/test_lecture_1.py` asserts every number they
8
+ print against the chapter that states it.
9
+
10
+ Run them from the repository root:
11
+
12
+ ```
13
+ cd cis6270
14
+ PYTHONPATH=. python lecture_1/probability.py
15
+ PYTHONPATH=. python -m pytest tests/test_lecture_1.py -q
16
+ ```
17
+
18
+ ## The files
19
+
20
+ | File | Command | What it works through |
21
+ | --- | --- | --- |
22
+ | `probability.py` | `python lecture_1/probability.py` | Sections 1.3 to 1.11. The two-token DNA distribution, the expected adenine count against a Monte Carlo ladder, the worked variance, the three peptides and their covariance matrix, the noising equation, the motif-and-binding table with its marginals and conditionals, Bayes' rule, and the independence check. |
23
+ | `linear_algebra.py` | `python lecture_1/linear_algebra.py` | Sections 1.12, 1.13, 1.15, 1.16, 1.17 and 1.18. The simplex and a mixture, the softmax of `z = (2, 1, 0)`, one step of the two-state chain, the eigenvalues of `A = [[2, 1], [1, 2]]`, powers along an eigendirection, the telescoping `A^n = V Lambda^n V^{-1}`, the stationary distribution and its rate, and `e^{tA}` by both the series and the eigenvalues. |
24
+ | `calculus.py` | `python lecture_1/calculus.py` | Sections 1.22 to 1.30. The Jacobian by automatic differentiation against the chapter's closed form, the determinant as local volume change, change of variables in one and in two dimensions, the log-determinant of a composition, the two inverse branches of the squaring map, the gradient and directional derivative, the divergence of three fields, and the continuity equation checked numerically on `v(x) = x`. |
25
+ | `fields.py` | `python lecture_1/fields.py` | Sections 1.31 and 1.32. The score of the standard Gaussian and of `N(1, 4)`, the normalizing constant differentiated away, the Laplacian of the bowl `x_1^2 + 3 x_2^2` and of the saddle `x_1^2 - x_2^2`, the heat equation at `D = 1/2` on the widening Gaussian `N(0, t)`, and the Fokker-Planck equation with its two reductions, to the continuity equation at `sigma_t = 0` and to the heat equation at `b_t = 0`. The same drift and noise drive an Euler-Maruyama walk, whose sample variance tracks the elapsed time. Chapter 3 starts from both sections. |
26
+ | `information.py` | `python lecture_1/information.py` | Sections 1.33 to 1.35. Information content, entropy, the cross-entropy of `p = (0.8, 0.2)` scored by `q = (0.6, 0.4)`, the split `H(p, q) = H(p) + KL(p \|\| q)`, nonnegativity and asymmetry, and maximum likelihood on ten DNA bases as a KL minimization. |
27
+ | `optimization.py` | `python lecture_1/optimization.py` | Sections 1.36 to 1.38. Gradient descent on `(theta - 3)^2`, an equality constraint through its Lagrangian, normalization as a constraint, the maximum-entropy derivation that produces the exponential family at a GC target of 0.6, the KKT conditions on a bounded quadratic, and the same likelihood problem solved on logits through the softmax. |
28
+
29
+ ## The flags
30
+
31
+ Every script takes `--steps`, `--seed`, `--out` and `--quiet`, so the flags learned once apply
32
+ across the course. `--steps` drives the iteration count of each script, and every flag a script
33
+ takes changes a line of its report.
34
+
35
+ | Flag | Where it changes the report |
36
+ | --- | --- |
37
+ | `--steps` | `probability.py`, repeats of the ten-sample Monte Carlo estimate, default 5000. `linear_algebra.py`, iterations of `p_{t+1} = P^T p_t`, default 10. `calculus.py`, Euler steps along the worked flow, default 200. `fields.py`, Euler-Maruyama steps over `[0, 1]`, default 200. `information.py`, repeats of the ten-sample cross-entropy estimate, default 5000. `optimization.py`, gradient-descent steps, default 600. |
38
+ | `--samples` | `probability.py`, the largest Monte Carlo sample size, default 1000. `fields.py`, paths simulated along the SDE, default 20000. `information.py`, the draws behind the sampled cross-entropy and the random distribution pairs, default 20000. `optimization.py`, the competing distributions drawn at the same GC target, default 4000. |
39
+ | `--grid` | `calculus.py` and `fields.py`, the points used in the numerical checks, default 1000. |
40
+ | `--seed` | The Monte Carlo draws, the simulated paths and the random distributions. The same seed gives the same report. |
41
+ | `--out` | A directory for `config.json` and `report.json`, written through `cis6270.runner.save_run`. |
42
+ | `--quiet` | Print the closing report and nothing else. |
43
+
44
+ ## What a default run prints
45
+
46
+ | Script | Headline numbers |
47
+ | --- | --- |
48
+ | `probability.py` | `E[h(X)] = 1.1000`, Monte Carlo estimate `1.1050` at `N = 1000`, `Var(h(X)) = 0.6900`, spread of 5000 ten-sample averages `0.2616` against the predicted `0.2627`, `rho_XY = 0.9449`, `p(X = 1 \| Y = 1) = 0.8182`, association `0.0960`. |
49
+ | `linear_algebra.py` | `softmax(z)_1 = 0.6652`, `lambda_1 = 3.0000`, `x_10` first entry `118099`, `p*_1 = 0.6000`, second eigenvalue `0.5000`, `e^{tA}_11 = 1.2275` at `t = 0.1`, truncation error `0.0002`. |
50
+ | `calculus.py` | `cos(1) = 0.5403`, `det J_phi = 4.0000` at `x_1 = 2`, `p_Y = 0.1667` inside the image of `A`, `log\|det J\| = 3.1781` for the composition, continuity residual `0.0000`, `log p = -1.0000` along the flow at `t = 1`. |
51
+ | `fields.py` | `s(3) = -3.0000`, `s(5) = -1.0000` under `N(1, 4)`, Laplacian `8.0000` on the bowl and `0.0000` on the saddle, `p_1(0) = 0.3989`, `d p_t(0)/dt = -0.1995`, `D` times the Laplacian `-0.1995`, every residual `0.0000`, sample variance `1.0130` at `t = 1` over 20000 paths. |
52
+ | `information.py` | `H(p) = 0.5004`, `H(p, q) = 0.5919`, `KL(p \|\| q) = 0.0915`, `KL(q \|\| p) = 0.1046`, spread of 5000 ten-sample estimates `0.0502` against the predicted `0.0513`, empirical loss `1.2799` nats, uniform loss `1.3863` nats, `KL(p_hat \|\| uniform) = 0.1064`. |
53
+ | `optimization.py` | `theta = 3.0000` after 600 steps, `lambda = -1.0000` on the equality constraint, `lambda = 0.4055` for a GC target of 0.6, `H(p*) = 1.3662` nats, best drawn competitor `1.3661` nats, `lambda = 4.0000` on the active bound, softmax descent loss `1.2799` nats. |
54
+
55
+ Every one of these runs in a few seconds on one core. The sampled rows are the ones that move with
56
+ `--seed`. `probability.py --steps 50000` closes the measured spread to `0.2624` and
57
+ `information.py --steps 50000` closes it to `0.0510`, at the cost of a second each.
58
+
59
+ ## What is simplified
60
+
61
+ - Every script runs in double precision. Chapter 1 quotes five decimal places in places, and the
62
+ single precision used by the training lectures would not reach them.
63
+ - The Monte Carlo sections redraw their samples, so the sampled values move with `--seed`. The
64
+ chapter's own ten-sample draw gave 1.2 against the exact 1.1, and that particular draw is not
65
+ reproduced. The exact value and the `1/sqrt(N)` error scale are.
66
+ - The continuity equation is checked by central differences at interior points of the support of
67
+ `p_t`, away from the two ends, where a one-sided difference would be needed. The Fokker-Planck
68
+ residual of `fields.py` uses the same differences, with a second difference for the Laplacian, so
69
+ it sits near `1e-8` and not at zero.
70
+ - `fields.py` takes the Brownian increment of `dX_t = b_t(X_t) dt + sigma_t dW_t` as a scaled
71
+ Gaussian draw per step. Chapter 3 defines Brownian motion properly. With a constant drift and a
72
+ constant noise magnitude the Euler-Maruyama walk has the exact law of the chapter's `N(bt, t)`, so
73
+ the gap the script prints is Monte Carlo error alone.
74
+ - The branch density `1/(2 sqrt(y))` is integrated numerically over `[0.01, 1]`, because the
75
+ integrand grows without bound at the origin. Its value over the full `[0, 1]` comes from the
76
+ antiderivative the chapter gives.
77
+ - The random competitors in the maximum-entropy check and the random pairs in the KL check confirm
78
+ the chapter's inequalities on a finite sample. They are a check, not the proof, which the chapter
79
+ gives in full. The competitors cover the two free splits of the GC-constrained family, one inside
80
+ `{A, T}` and one inside `{C, G}`, drawn independently.
81
+ - Each script has its own three-line `show` helper for the side-by-side printing and its own
82
+ `WORKED` dictionary of computed values. Neither belongs in `cis6270/`, because no other lecture
83
+ prints a chapter number beside a computed one.
lecture_1/calculus.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Jacobians, change of variables, divergence and the continuity equation.
2
+
3
+ A map moves points, and the Jacobian is the linear map it looks like near one of them. Its
4
+ determinant is the local volume factor, so the density at an image point is the density at its
5
+ source divided by that factor, the log-determinants add across a composition, and for a
6
+ many-to-one map the density is a sum of one corrected term per inverse branch. In continuous time
7
+ that factor becomes the divergence of a velocity field, and the continuity equation holds it in
8
+ balance with the change in density at every point.
9
+
10
+ Chapter 1, Sections 1.22 to 1.30 of the notes. Runs on the maps of Sections 1.22.2 and 1.23, the
11
+ squaring map of Section 1.26, and the expanding flow v(x) = x of Section 1.30.2. Every Jacobian
12
+ is taken by automatic differentiation and checked against the chapter's closed form.
13
+
14
+ python lecture_1/calculus.py # every worked example
15
+ python lecture_1/calculus.py --grid 4000 # finer grids in the numerical checks
16
+ python lecture_1/calculus.py --steps 2000 # a finer Euler walk along the worked flow
17
+ """
18
+
19
+ # %% 01. Imports and configuration
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import math
24
+
25
+ import torch
26
+
27
+ from cis6270.runner import print_report, save_run, set_seed
28
+
29
+ torch.set_default_dtype(torch.float64) # Chapter 1 is arithmetic, and five printed digits need it.
30
+
31
+
32
+ A_SCALE = torch.tensor([[2.0, 0.0], [0.0, 3.0]]) # The map of Section 1.23, which scales area by 6.
33
+
34
+
35
+ WORKED: dict[str, float] = {} # Every computed value, by label, for tests/test_lecture_1.py.
36
+
37
+
38
+ def show(label: str, by_hand: float, computed: float, quiet: bool = False) -> None:
39
+ """Print the chapter's hand-worked value beside the value this file computes, and keep it."""
40
+ WORKED[label] = computed # The test suite asserts these against the chapter's own numbers.
41
+ if not quiet: # Under --quiet only the closing report is printed.
42
+ computed = computed + 0.0 # Adding zero prints a negative zero as a plain zero.
43
+ print(f" {label:<36s} chapter {by_hand:>11.5f} computed {computed:>11.5f}"
44
+ f" gap {abs(by_hand - computed):.1e}")
45
+
46
+
47
+ def phi_mixed(x: torch.Tensor) -> torch.Tensor:
48
+ """The map (x_1^2 + x_2, sin x_1 + 3 x_2) of Section 1.22.2, nonlinear in its first component."""
49
+ return torch.stack([x[0] ** 2 + x[1], torch.sin(x[0]) + 3.0 * x[1]])
50
+
51
+
52
+ def phi_square(x: torch.Tensor) -> torch.Tensor:
53
+ """The map (x_1^2, x_2) of Section 1.23, one-to-one once the domain is restricted to x_1 > 0."""
54
+ return torch.stack([x[0] ** 2, x[1]])
55
+
56
+
57
+ def psi_scale(y: torch.Tensor) -> torch.Tensor:
58
+ """The linear map y -> A y with A = diag(2, 3), composed after phi_square in Section 1.25."""
59
+ return A_SCALE @ y
60
+
61
+
62
+ def f_bowl(x: torch.Tensor) -> torch.Tensor:
63
+ """The scalar field f(x_1, x_2) = x_1^2 + 2 x_2^2 of Section 1.27.1."""
64
+ return x[0] ** 2 + 2.0 * x[1] ** 2
65
+
66
+
67
+ def divergence(field, x: torch.Tensor) -> float:
68
+ """Section 1.29: the divergence is the sum of d v_i / d x_i, which is the trace of J_v."""
69
+ return float(torch.autograd.functional.jacobian(field, x).trace())
70
+
71
+
72
+ # %% 02. The Jacobian matrix and the local linear approximation
73
+ def jacobian(quiet: bool = False) -> dict:
74
+ """Sections 1.22.2 and 1.22.3: J_phi at (1, 0), and the displacement it predicts."""
75
+ point = torch.tensor([1.0, 0.0]) # The point at which the chapter evaluates the Jacobian.
76
+ computed = torch.autograd.functional.jacobian(phi_mixed, point) # [2, 2], by backpropagation.
77
+ closed_form = torch.tensor([[2.0 * point[0], 1.0], # The chapter's entries, 2 x_1 and 1.
78
+ [torch.cos(point[0]), 3.0]]) # cos x_1 and 3; [2, 2].
79
+ delta = torch.tensor([0.01, -0.02]) # The small input displacement of Section 1.22.3.
80
+ predicted = computed @ delta # J_phi(x) Delta x, the first-order change in the output; [2].
81
+ exact = phi_mixed(point + delta) - phi_mixed(point) # The change the full map produces; [2].
82
+ show("J_phi(1, 0)_11", 2.0, float(computed[0, 0]), quiet)
83
+ show("J_phi(1, 0)_21 = cos(1)", 0.5403, float(computed[1, 0]), quiet)
84
+ show("largest |autograd - closed form|", 0.0, float((computed - closed_form).abs().max()), quiet)
85
+ show("predicted change, first output", 0.0, float(predicted[0]), quiet)
86
+ show("predicted change, second output", -0.0546, float(predicted[1]), quiet)
87
+ show("exact change, first output", 0.0001, float(exact[0]), quiet)
88
+ show("exact change, second output", -0.05464, float(exact[1]), quiet)
89
+ return {"jacobian": computed, "predicted": predicted, "exact": exact}
90
+
91
+
92
+ # %% 03. The Jacobian determinant as local volume change
93
+ def determinant(quiet: bool = False) -> dict:
94
+ """Section 1.23: the absolute determinant is the factor by which a small region changes area."""
95
+ corners = torch.tensor([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]) # The unit square.
96
+ mapped = corners @ A_SCALE.T # The image rectangle [0, 2] by [0, 3]; [4, 2].
97
+ area = float((mapped[1, 0] - mapped[0, 0]) * (mapped[3, 1] - mapped[0, 1])) # Width times height.
98
+ collapsing = torch.tensor([[1.0, 0.0], [0.0, 0.0]]) # A map that flattens the plane onto a line.
99
+ stretch = torch.autograd.functional.jacobian(phi_square, torch.tensor([2.0, 1.0])) # At x_1 = 2.
100
+ small = torch.tensor([0.25, 1.0]) # A point where the same map contracts, since 2 x_1 is below 1.
101
+ squeeze = torch.autograd.functional.jacobian(phi_square, small) # J_phi at x_1 = 0.25; [2, 2].
102
+ show("det A for diag(2, 3)", 6.0, float(torch.linalg.det(A_SCALE)), quiet)
103
+ show("area of the image rectangle", 6.0, area, quiet)
104
+ show("det of the collapsing map", 0.0, float(torch.linalg.det(collapsing)), quiet)
105
+ show("det J_phi at x_1 = 2", 4.0, float(torch.linalg.det(stretch)), quiet)
106
+ show("det J_phi at x_1 = 0.25", 0.5, float(torch.linalg.det(squeeze)), quiet)
107
+ return {"area": area, "stretch": float(torch.linalg.det(stretch))}
108
+
109
+
110
+ # %% 04. Change of variables in one and in two dimensions
111
+ def change_of_variables(grid: int, quiet: bool = False) -> dict:
112
+ """Sections 1.24.1 and 1.24.2: p_Y(phi(x)) = p_X(x) / |det J_phi(x)| on the two worked maps."""
113
+ inverse_slope = 0.5 # d phi^{-1}/dy for phi^{-1}(y) = (y - 1)/2, the reciprocal of phi' = 2.
114
+ p_y = 1.0 * abs(inverse_slope) # p_Y(y) = p_X(phi^{-1}(y)) |d phi^{-1}/dy|, so p_Y = 1/2.
115
+ preimage = ((1.4 - 1.0) / 2.0, (2.0 - 1.0) / 2.0) # The inverse image of B = [1.4, 2.0].
116
+ mass = (preimage[1] - preimage[0]) * 1.0 # P(X in [0.2, 0.5]) under the uniform density.
117
+ line = 1.0 + (torch.arange(grid) + 0.5) * (2.0 / grid) # Midpoints of [1, 3]; [grid].
118
+ normalization = float(torch.full_like(line, p_y).sum() * (2.0 / grid)) # Integral of p_Y there.
119
+ flat = 1.0 / float(torch.linalg.det(A_SCALE)) # p_Y = 1/6 inside the image of the unit square.
120
+ step = 1.0 / grid # Midpoint spacing on the unit interval of the first input coordinate.
121
+ x_one = (torch.arange(grid) + 0.5) * step # Midpoints of (0, 1) in that coordinate; [grid].
122
+ volume_factor = 2.0 * x_one # |det J_phi| = 2 x_1 for phi = (x_1^2, x_2), at each point; [grid].
123
+ output_density = 1.0 / (2.0 * (x_one ** 2).sqrt()) # p_Y(y) = 1/(2 sqrt(y_1)) at y_1 = x_1^2.
124
+ residual = float((output_density * volume_factor - 1.0).abs().max()) # Should recover p_X = 1.
125
+ transported = float((output_density * volume_factor).sum() * step) # Total mass by substitution.
126
+ show("preimage of [1.4, 2.0], lower end", 0.2, preimage[0], quiet)
127
+ show("preimage of [1.4, 2.0], upper end", 0.5, preimage[1], quiet)
128
+ show("P(X in the preimage)", 0.3, mass, quiet)
129
+ show("p_Y on [1, 3] after Y = 2X + 1", 0.5, p_y, quiet)
130
+ show("integral of p_Y over [1, 3]", 1.0, normalization, quiet)
131
+ show("p_Y inside the image of A", 1.0 / 6.0, flat, quiet)
132
+ show("density times area for A", 1.0, flat * 6.0, quiet)
133
+ show("p_Y(y_1 = 1) for the x_1^2 map", 0.5, 1.0 / (2.0 * math.sqrt(1.0)), quiet)
134
+ show("largest |p_Y |det J| - p_X|", 0.0, residual, quiet)
135
+ show("total mass after the x_1^2 map", 1.0, transported, quiet)
136
+ return {"p_y": p_y, "flat": flat, "residual": residual, "transported": transported}
137
+
138
+
139
+ # %% 05. Composed maps and the log-density form
140
+ def composition(quiet: bool = False) -> dict:
141
+ """Section 1.25: log|det J| adds across a composition, so two factors become one sum."""
142
+ point = torch.tensor([2.0, 1.0]) # The chapter evaluates the composition at x_1 = 2.
143
+ inner = torch.autograd.functional.jacobian(phi_square, point) # J_phi(x), det 2 x_1 = 4; [2, 2].
144
+ outer = torch.autograd.functional.jacobian(psi_scale, phi_square(point)) # J_psi at phi(x); [2, 2].
145
+ chained = outer @ inner # The chain rule of Proposition 1.1, giving diag(8, 3); [2, 2].
146
+ composed = torch.autograd.functional.jacobian(lambda x: psi_scale(phi_square(x)), point)
147
+ log_inner = math.log(abs(float(torch.linalg.det(inner)))) # log 4 = 1.3863.
148
+ log_outer = math.log(abs(float(torch.linalg.det(outer)))) # log 6 = 1.7918.
149
+ log_density = math.log(1.0) - (log_inner + log_outer) # log p_Y = log p_X - log|det J|.
150
+ show("det J_psi", 6.0, float(torch.linalg.det(outer)), quiet)
151
+ show("chained Jacobian, entry 11", 8.0, float(chained[0, 0]), quiet)
152
+ show("det of the composition", 24.0, float(torch.linalg.det(composed)), quiet)
153
+ show("log 4", 1.3863, log_inner, quiet)
154
+ show("log 6", 1.7918, log_outer, quiet)
155
+ show("log 4 + log 6 = log 24", 3.1781, log_inner + log_outer, quiet)
156
+ show("log p_Y from a unit base density", -3.1781, log_density, quiet)
157
+ return {"log_sum": log_inner + log_outer, "log_density": log_density}
158
+
159
+
160
+ # %% 06. Local invertibility and several inverse branches
161
+ def branches(grid: int, quiet: bool = False) -> dict:
162
+ """Section 1.26: for Y = X^2 on a uniform X, the two preimages each contribute to p_Y(y)."""
163
+ def branch_density(y: float) -> float:
164
+ roots = (math.sqrt(y), -math.sqrt(y)) # The two solutions of x^2 = y inside the support.
165
+ return sum(0.5 / abs(2.0 * root) for root in roots) # Sum of p_X(x) / |phi'(x)| over roots.
166
+
167
+ lower = 0.01 # The integral is cut at 0.01 because the integrand grows without bound at zero.
168
+ step = (1.0 - lower) / grid # Midpoint spacing on [0.01, 1].
169
+ points = lower + (torch.arange(grid) + 0.5) * step # Midpoints of the integration interval.
170
+ partial = float((1.0 / (2.0 * points.sqrt())).sum() * step) # Midpoint integral of 1/(2 sqrt y).
171
+ show("p_Y(0.25), two branches at ±0.5", 1.0, branch_density(0.25), quiet)
172
+ show("p_Y(0.04), two branches at ±0.2", 2.5, branch_density(0.04), quiet)
173
+ show("integral of p_Y over [0.01, 1]", 1.0 - math.sqrt(lower), partial, quiet)
174
+ return {"at_quarter": branch_density(0.25), "partial": partial}
175
+
176
+
177
+ # %% 07. Scalar fields, the gradient and the directional derivative
178
+ def gradient(quiet: bool = False) -> dict:
179
+ """Sections 1.27.1 and 1.27.2: grad f at (1, 2), its steepest direction and a directional rate."""
180
+ point = torch.tensor([1.0, 2.0]) # The point at which the chapter evaluates the gradient.
181
+ grad = torch.autograd.functional.jacobian(f_bowl, point) # (2 x_1, 4 x_2) = (2, 8); [2].
182
+ steepest = grad / grad.norm() # The unit vector of fastest increase; [2].
183
+ along_x1 = float(grad @ torch.tensor([1.0, 0.0])) # D_u f for u the first basis direction.
184
+ along_x2 = float(grad @ torch.tensor([0.0, 1.0])) # D_u f for u the second basis direction.
185
+ show("grad f(1, 2), first entry", 2.0, float(grad[0]), quiet)
186
+ show("grad f(1, 2), second entry", 8.0, float(grad[1]), quiet)
187
+ show("|grad f(1, 2)| = sqrt(68)", math.sqrt(68.0), float(grad.norm()), quiet)
188
+ show("steepest direction, first entry", 0.243, float(steepest[0]), quiet)
189
+ show("steepest direction, second entry", 0.970, float(steepest[1]), quiet)
190
+ show("D_u f along (1, 0)", 2.0, along_x1, quiet)
191
+ show("D_u f along (0, 1)", 8.0, along_x2, quiet)
192
+ return {"gradient": grad, "along_x1": along_x1}
193
+
194
+
195
+ # %% 08. Divergence
196
+ def divergences(quiet: bool = False) -> dict:
197
+ """Section 1.29: the divergence of the chapter's three fields is -3, then 1, then 0."""
198
+ point = torch.tensor([1.0, 1.0]) # The divergences below are the same at every point.
199
+ contract = divergence(lambda x: torch.stack([-x[0], -2.0 * x[1]]), point) # -1 + (-2) = -3.
200
+ expand = divergence(lambda x: torch.stack([2.0 * x[0], -x[1]]), point) # 2 - 1 = 1.
201
+ rotate = divergence(lambda x: torch.stack([-x[1], x[0]]), point) # 0 + 0 = 0.
202
+ show("div of (-x_1, -2 x_2)", -3.0, contract, quiet)
203
+ show("div of (2 x_1, -x_2)", 1.0, expand, quiet)
204
+ show("div of (-x_2, x_1)", 0.0, rotate, quiet)
205
+ show("area factor e^{div t} at t = 0.5", 1.649, math.exp(expand * 0.5), quiet)
206
+ return {"contract": contract, "expand": expand, "rotate": rotate}
207
+
208
+
209
+ # %% 09. The continuity equation on the worked flow
210
+ def continuity(grid: int, steps: int, quiet: bool = False) -> dict:
211
+ """Section 1.30.2: v(x) = x stretches a uniform source, with p_t(x) = e^{-t} on [0, e^t].
212
+
213
+ Central differences give the continuity residual at interior points, and one Euler walk
214
+ accumulates the log-density rate -div v of Section 1.30.1 along a trajectory.
215
+ """
216
+ def flux(time: float, position):
217
+ return math.exp(-time) * position # j_t(x) = p_t(x) v_t(x), with p_t = e^{-t} and v(x) = x.
218
+
219
+ t, h = 0.5, 1e-4 # The time of the residual check, and the central-difference step.
220
+ x = torch.linspace(0.1, 1.4, grid) # Interior points of the support [0, e^{0.5}]; [grid].
221
+ time_rate = (math.exp(-(t + h)) - math.exp(-(t - h))) / (2.0 * h) # d p_t / d t at fixed x.
222
+ flux_rate = (flux(t, x + h) - flux(t, x - h)) / (2.0 * h) # d (p_t v) / d x; [grid].
223
+ residual = float((time_rate + flux_rate).abs().max()) # The continuity equation, point by point.
224
+ x_t, log_p, dt = 0.5, 0.0, 1.0 / max(steps, 1) # One trajectory started inside the source.
225
+ for _ in range(max(steps, 1)):
226
+ log_p -= 1.0 * dt # d/dt log p_t(x_t) = -div v = -1 for the field v(x) = x.
227
+ x_t = x_t + x_t * dt # Euler step on dx_t/dt = v(x_t) = x_t.
228
+ show("largest continuity residual", 0.0, residual, quiet)
229
+ show("log p along the trajectory at t = 1", -1.0, log_p, quiet)
230
+ show("p_t(x_t) at t = 1", 0.3679, math.exp(log_p), quiet)
231
+ show("x_t at t = 1, exact 0.5e", 0.5 * math.e, x_t, quiet)
232
+ show("density at t = log 2", 0.5, math.exp(-math.log(2.0)), quiet)
233
+ show("mass at t = log 2", 1.0, 2.0 * math.exp(-math.log(2.0)), quiet)
234
+ show("mass at t = 1", 1.000, math.e * math.exp(-1.0), quiet)
235
+ return {"residual": residual, "log_p": log_p, "x_t": x_t}
236
+
237
+
238
+ # %% 10. The report
239
+ def main() -> None:
240
+ parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
241
+ parser.add_argument("--grid", type=int, default=1000, help="points in the numerical checks")
242
+ parser.add_argument("--steps", type=int, default=200, help="Euler steps along the worked flow")
243
+ parser.add_argument("--seed", type=int, default=0, help="random seed, for a repeatable run")
244
+ parser.add_argument("--out", type=str, default="", help="directory for the run's output")
245
+ parser.add_argument("--quiet", action="store_true", help="print only the final report")
246
+ args = parser.parse_args()
247
+
248
+ set_seed(args.seed) # Seed first, so the same seed gives the same run.
249
+ if not args.quiet:
250
+ print("Chapter 1, Sections 1.22 to 1.30, worked example by worked example\n")
251
+ local = jacobian(args.quiet)
252
+ volume = determinant(args.quiet)
253
+ moved = change_of_variables(args.grid, args.quiet)
254
+ stacked = composition(args.quiet)
255
+ folded = branches(args.grid, args.quiet)
256
+ slope = gradient(args.quiet)
257
+ fields = divergences(args.quiet)
258
+ flow = continuity(args.grid, args.steps, args.quiet)
259
+
260
+ report = {
261
+ "J_phi(1, 0)_21 = cos(1)": float(local["jacobian"][1, 0]),
262
+ "det J_phi at x_1 = 2": volume["stretch"],
263
+ "p_Y on [1, 3] after Y = 2X + 1": moved["p_y"],
264
+ "p_Y inside the image of A": moved["flat"],
265
+ "mass after the x_1^2 map": moved["transported"],
266
+ "log|det J| of the composition": stacked["log_sum"],
267
+ "p_Y(0.25) summed over branches": folded["at_quarter"],
268
+ "D_u f along (1, 0)": slope["along_x1"],
269
+ "div of (2 x_1, -x_2)": fields["expand"],
270
+ "largest continuity residual": flow["residual"],
271
+ "log p along the flow at t = 1": flow["log_p"],
272
+ "x_t at t = 1 after Euler steps": flow["x_t"],
273
+ }
274
+ print_report("Calculus, Sections 1.22 to 1.30", report)
275
+ save_run(args.out, config=vars(args), report=report)
276
+
277
+
278
+ if __name__ == "__main__": # Execute when launched directly.
279
+ main() # Work every example of Sections 1.22 to 1.30 and print the report.
lecture_1/fields.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Score functions, the Laplacian and the Fokker-Planck equation.
2
+
3
+ The score of a density is the gradient of its logarithm, a vector field of the same shape as a
4
+ velocity field, and the normalizing constant differentiates away. The Laplacian is the divergence
5
+ of the gradient, and it measures the curvature that drives diffusion, so the heat equation sets the
6
+ rate of change of a density proportional to it. One statement about the density holds both
7
+ mechanisms at once, the Fokker-Planck equation, and it returns the continuity equation when the
8
+ noise magnitude is zero and the heat equation when the drift is.
9
+
10
+ Chapter 1, Sections 1.31 and 1.32 of the notes. Runs on the Gaussians of Section 1.31, the bowl and
11
+ the saddle of Section 1.32, the widening Gaussian p_t = N(0, t) of Section 1.32.1 and the expanding
12
+ flow v(x) = x of Section 1.30.2. Prints the chapter's number beside the computed one.
13
+
14
+ python lecture_1/fields.py # every worked example
15
+ python lecture_1/fields.py --steps 2000 # a finer Euler-Maruyama walk
16
+ python lecture_1/fields.py --quiet # the closing report only
17
+ """
18
+
19
+ # %% 01. Imports and configuration
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import math
24
+
25
+ import torch
26
+
27
+ from cis6270.runner import print_report, save_run, set_seed
28
+
29
+ torch.set_default_dtype(torch.float64) # Chapter 1 is arithmetic, and five printed digits need it.
30
+
31
+
32
+ SCORE_POINTS = torch.tensor([3.0, -2.0, 0.0]) # The three states scored in Section 1.31.
33
+ WIDE_MEAN, WIDE_VARIANCE = 1.0, 4.0 # The general Gaussian N(mu, sigma^2) of Section 1.31.
34
+ DRIFT = 0.5 # The constant drift b_t(x) = 0.5 of the combined process in Section 1.32.2.
35
+ NOISE = 1.0 # The noise magnitude sigma_t, so that D = sigma_t^2 / 2 = 0.5 as in Section 1.32.1.
36
+
37
+
38
+ WORKED: dict[str, float] = {} # Every computed value, by label, for tests/test_lecture_1.py.
39
+
40
+
41
+ def show(label: str, by_hand: float, computed: float, quiet: bool = False) -> None:
42
+ """Print the chapter's hand-worked value beside the value this file computes, and keep it."""
43
+ WORKED[label] = computed # The test suite asserts these against the chapter's own numbers.
44
+ if not quiet: # Under --quiet only the closing report is printed.
45
+ computed = computed + 0.0 # Adding zero prints a negative zero as a plain zero.
46
+ print(f" {label:<36s} chapter {by_hand:>11.5f} computed {computed:>11.5f}"
47
+ f" gap {abs(by_hand - computed):.1e}")
48
+
49
+
50
+ def log_gaussian(x: torch.Tensor, mu: float = 0.0, variance: float = 1.0) -> torch.Tensor:
51
+ """log N(x; mu, sigma^2) = -(x - mu)^2 / (2 sigma^2) - (1/2) log(2 pi sigma^2)."""
52
+ normalizer = 0.5 * math.log(2.0 * math.pi * variance) # The term with no x in it.
53
+ return -((x - mu) ** 2) / (2.0 * variance) - normalizer
54
+
55
+
56
+ def gaussian(t: float, x: torch.Tensor) -> torch.Tensor:
57
+ """p_t = N(0, t) in one dimension, the widening Gaussian of Section 1.32.1."""
58
+ return (-(x ** 2) / (2.0 * t)).exp() / math.sqrt(2.0 * math.pi * t)
59
+
60
+
61
+ def score(log_density, x: torch.Tensor) -> torch.Tensor:
62
+ """Section 1.31: s(x) = grad_x log p(x), taken by automatic differentiation."""
63
+ point = x.detach().clone().requires_grad_(True) # A leaf to differentiate against; [N].
64
+ total = log_density(point).sum() # The points do not interact, so one pass gives every score.
65
+ gradient, = torch.autograd.grad(total, point) # d/dx log p at each point; [N].
66
+ return gradient
67
+
68
+
69
+ def laplacian(field, x: torch.Tensor) -> float:
70
+ """Section 1.32: the Laplacian is the divergence of the gradient, the trace of the Hessian."""
71
+ return float(torch.autograd.functional.hessian(field, x).trace())
72
+
73
+
74
+ def fokker_planck_residual(p, b, sigma: float, x: torch.Tensor, t: float) -> float:
75
+ """Section 1.32.2: d p_t / d t + div(b_t p_t) - (1/2) sigma_t^2 Laplacian p_t, by differences.
76
+
77
+ Setting sigma to zero leaves the continuity equation of Section 1.30, and setting b to zero
78
+ leaves the heat equation of Section 1.32.1, so one residual covers all three statements.
79
+ """
80
+ h = 1e-4 # The central-difference step, small enough for five digits in double precision.
81
+ time_rate = (p(t + h, x) - p(t - h, x)) / (2.0 * h) # d p_t(x) / d t at fixed x; [grid].
82
+ flux = (b(x + h) * p(t, x + h) - b(x - h) * p(t, x - h)) / (2.0 * h) # div(b_t p_t); [grid].
83
+ curvature = (p(t, x + h) - 2.0 * p(t, x) + p(t, x - h)) / h ** 2 # Laplacian p_t; [grid].
84
+ return float((time_rate + flux - 0.5 * sigma ** 2 * curvature).abs().max())
85
+
86
+
87
+ # %% 02. The score of a Gaussian
88
+ def scores(quiet: bool = False) -> dict:
89
+ """Section 1.31: the score of N(0, 1) is -x, and of N(mu, sigma^2) it is -(x - mu)/sigma^2."""
90
+ standard = score(log_gaussian, SCORE_POINTS) # s(x) at x = 3, -2 and 0; [3].
91
+ closed_form = -SCORE_POINTS # The chapter's result, with the log(2 pi) term differentiated away.
92
+ unnormalized = score(lambda z: -(z ** 2) / 2.0, SCORE_POINTS) # The same density without its Z.
93
+ wide = score(lambda z: log_gaussian(z, WIDE_MEAN, WIDE_VARIANCE), torch.tensor([5.0])) # [1].
94
+ show("s(x) at x = 3", -3.0, float(standard[0]), quiet)
95
+ show("s(x) at x = -2", 2.0, float(standard[1]), quiet)
96
+ show("s(x) at the mode x = 0", 0.0, float(standard[2]), quiet)
97
+ show("largest |s(x) + x|", 0.0, float((standard - closed_form).abs().max()), quiet)
98
+ show("largest change from dropping Z", 0.0, float((unnormalized - standard).abs().max()), quiet)
99
+ show("s(5) under N(1, 4)", -1.0, float(wide[0]), quiet)
100
+ return {"standard": standard, "wide": float(wide[0])}
101
+
102
+
103
+ # %% 03. The Laplacian of a scalar field
104
+ def curvatures(quiet: bool = False) -> dict:
105
+ """Section 1.32: the bowl x_1^2 + 3 x_2^2 has Laplacian 8, and the saddle x_1^2 - x_2^2 has 0."""
106
+ def bowl(x: torch.Tensor) -> torch.Tensor:
107
+ return x[0] ** 2 + 3.0 * x[1] ** 2 # The field whose two curvatures are 2 and 6.
108
+
109
+ def saddle(x: torch.Tensor) -> torch.Tensor:
110
+ return x[0] ** 2 - x[1] ** 2 # The field whose two curvatures cancel.
111
+
112
+ point = torch.tensor([1.0, 2.0]) # The Laplacians below are the same at every point.
113
+ second = torch.autograd.functional.hessian(bowl, point) # The two pure second derivatives; [2, 2].
114
+ h = 1e-3 # The spacing of the four neighbors used for the average reading of the Laplacian.
115
+ steps = torch.tensor([[h, 0.0], [-h, 0.0], [0.0, h], [0.0, -h]]) # The four offsets; [4, 2].
116
+ neighbors = sum(bowl(point + step) for step in steps) - 4.0 * bowl(point) # Above the center.
117
+ show("d^2 f / d x_1^2", 2.0, float(second[0, 0]), quiet)
118
+ show("d^2 f / d x_2^2", 6.0, float(second[1, 1]), quiet)
119
+ show("Laplacian of x_1^2 + 3 x_2^2", 8.0, laplacian(bowl, point), quiet)
120
+ show("Laplacian from the four neighbors", 8.0, float(neighbors) / h ** 2, quiet)
121
+ show("Laplacian of the saddle", 0.0, laplacian(saddle, point), quiet)
122
+ return {"bowl": laplacian(bowl, point), "saddle": laplacian(saddle, point)}
123
+
124
+
125
+ # %% 04. The heat equation
126
+ def heat(grid: int, quiet: bool = False) -> dict:
127
+ """Section 1.32.1: at D = 1/2 the Gaussian N(0, t) solves d p_t / d t = D Laplacian p_t."""
128
+ diffusion = 0.5 * NOISE ** 2 # D = sigma_t^2 / 2, the coefficient the chapter takes as 1/2.
129
+ center = 1.0 / math.sqrt(2.0 * math.pi) # p_1(0), the height at the middle at t = 1.
130
+ time_rate = -0.5 / math.sqrt(2.0 * math.pi) # d p_t(0)/dt = -(1/2)(2 pi)^{-1/2} t^{-3/2} at t = 1.
131
+ curvature = laplacian(lambda z: gaussian(1.0, z), torch.tensor([0.0])) # Laplacian p_1 at 0.
132
+ x = torch.linspace(-3.0, 3.0, grid) # Points covering the bulk of the mass at t = 1; [grid].
133
+ residual = fokker_planck_residual(
134
+ gaussian, torch.zeros_like, NOISE, x, 1.0) # The heat equation, with no drift.
135
+ show("p_1(0) = 1 / sqrt(2 pi)", 0.39894, center, quiet)
136
+ show("d p_t(0) / d t at t = 1", -0.19947, time_rate, quiet)
137
+ show("Laplacian of p_1 at 0", -0.39894, curvature, quiet)
138
+ show("-p_1(0) / sigma^2 at sigma^2 = 1", -0.39894, -center / 1.0, quiet)
139
+ show("D times the Laplacian of p_1 at 0", -0.19947, diffusion * curvature, quiet)
140
+ show("largest heat-equation residual", 0.0, residual, quiet)
141
+ return {"center": center, "time_rate": time_rate, "curvature": curvature,
142
+ "flow": diffusion * curvature, "residual": residual}
143
+
144
+
145
+ # %% 05. Drift and diffusion together
146
+ def fokker_planck(grid: int, quiet: bool = False) -> dict:
147
+ """Section 1.32.2: a drift that translates and a noise that widens, in one equation.
148
+
149
+ With a constant drift b and sigma_t = 1 the density N(bt, t) solves the equation, since the
150
+ drift moves the center and the noise grows the variance at the rate of the heat equation.
151
+ """
152
+ def translating(t: float, x: torch.Tensor) -> torch.Tensor:
153
+ return gaussian(t, x - DRIFT * t) # N(bt, t), the center carried along by the drift.
154
+
155
+ def expanding(t: float, x: torch.Tensor) -> torch.Tensor:
156
+ return torch.full_like(x, math.exp(-t)) # p_t = e^{-t} on [0, e^t], Section 1.30.2.
157
+
158
+ x = torch.linspace(-3.0, 3.0, grid) # Points across the translating Gaussian; [grid].
159
+ both = fokker_planck_residual(translating, lambda z: torch.full_like(z, DRIFT), NOISE, x, 1.0)
160
+ inside = torch.linspace(0.1, 1.4, grid) # Interior points of the support at t = 0.5; [grid].
161
+ transport = fokker_planck_residual(expanding, lambda z: z, 0.0, inside, 0.5) # sigma_t = 0.
162
+ center = DRIFT * 1.0 # The mean of p_t at t = 1, which the drift alone sets.
163
+ show("center of p_t at t = 1", 0.5, center, quiet)
164
+ show("largest residual, drift and noise", 0.0, both, quiet)
165
+ show("largest residual at sigma_t = 0", 0.0, transport, quiet)
166
+ return {"both": both, "transport": transport, "center": center}
167
+
168
+
169
+ # %% 06. The process behind the density
170
+ def euler_maruyama(samples: int, steps: int, quiet: bool = False) -> dict:
171
+ """Section 1.32.2: dX_t = b_t(X_t) dt + sigma_t dW_t, simulated, against the density above.
172
+
173
+ The variance of p_t = N(bt, t) equals the elapsed time, so the sample mean and the sample
174
+ variance of the walk track bt and t. Both gaps shrink as --samples grows.
175
+ """
176
+ dt = 1.0 / max(steps, 1) # The time increment of one Euler-Maruyama step.
177
+ x_t = torch.zeros(max(samples, 1)) # Every path starts at the origin, so p_0 is a point mass.
178
+ mean_gap, variance_gap = 0.0, 0.0 # The largest departure from bt and from t along the walk.
179
+ for step in range(max(steps, 1)):
180
+ noise = torch.randn_like(x_t) # The Brownian increment dW_t, scaled by sqrt(dt); [samples].
181
+ x_t = x_t + DRIFT * dt + NOISE * math.sqrt(dt) * noise # One step of the chapter's SDE.
182
+ elapsed = (step + 1) * dt # The time reached after this step.
183
+ mean_gap = max(mean_gap, abs(float(x_t.mean()) - DRIFT * elapsed))
184
+ variance_gap = max(variance_gap, abs(float(x_t.var(unbiased=False)) - elapsed))
185
+ show("sample mean at t = 1, exact 0.5", 0.5, float(x_t.mean()), quiet)
186
+ show("sample variance at t = 1, exact 1", 1.0, float(x_t.var(unbiased=False)), quiet)
187
+ show("largest |sample mean - b t|", 0.0, mean_gap, quiet)
188
+ show("largest |sample variance - t|", 0.0, variance_gap, quiet)
189
+ return {"mean": float(x_t.mean()), "variance": float(x_t.var(unbiased=False)),
190
+ "mean_gap": mean_gap, "variance_gap": variance_gap, "steps": max(steps, 1)}
191
+
192
+
193
+ # %% 07. The report
194
+ def main() -> None:
195
+ parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
196
+ parser.add_argument("--grid", type=int, default=1000, help="points in the numerical checks")
197
+ parser.add_argument("--steps", type=int, default=200, help="Euler-Maruyama steps over [0, 1]")
198
+ parser.add_argument("--samples", type=int, default=20000, help="paths simulated along the SDE")
199
+ parser.add_argument("--seed", type=int, default=0, help="random seed for the simulated paths")
200
+ parser.add_argument("--out", type=str, default="", help="directory for the run's output")
201
+ parser.add_argument("--quiet", action="store_true", help="print only the final report")
202
+ args = parser.parse_args()
203
+
204
+ set_seed(args.seed) # Seed first, so the same seed gives the same walk.
205
+ if not args.quiet:
206
+ print("Chapter 1, Sections 1.31 and 1.32, worked example by worked example\n")
207
+ uphill = scores(args.quiet)
208
+ bowl = curvatures(args.quiet)
209
+ spreading = heat(args.grid, args.quiet)
210
+ combined = fokker_planck(args.grid, args.quiet)
211
+ walk = euler_maruyama(args.samples, args.steps, args.quiet)
212
+
213
+ report = {
214
+ "s(x) at x = 3": float(uphill["standard"][0]),
215
+ "s(5) under N(1, 4)": uphill["wide"],
216
+ "Laplacian of x_1^2 + 3 x_2^2": bowl["bowl"],
217
+ "Laplacian of the saddle": bowl["saddle"],
218
+ "p_1(0)": spreading["center"],
219
+ "d p_t(0) / d t at t = 1": spreading["time_rate"],
220
+ "Laplacian of p_1 at 0": spreading["curvature"],
221
+ "D times that Laplacian": spreading["flow"],
222
+ "heat-equation residual": spreading["residual"],
223
+ "Fokker-Planck residual": combined["both"],
224
+ "residual at sigma_t = 0": combined["transport"],
225
+ "Euler-Maruyama steps": walk["steps"],
226
+ "sample variance at t = 1": walk["variance"],
227
+ "largest |sample variance - t|": walk["variance_gap"],
228
+ }
229
+ print_report("Fields, Sections 1.31 and 1.32", report)
230
+ save_run(args.out, config=vars(args), report=report)
231
+
232
+
233
+ if __name__ == "__main__": # Execute when launched directly.
234
+ main() # Work every example of Sections 1.31 and 1.32 and print the report.
lecture_1/information.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entropy, cross-entropy, KL divergence and maximum likelihood, in nats.
2
+
3
+ Information content measures one observation, entropy averages it under the distribution that
4
+ produced it, cross-entropy averages it under a second distribution supplying the probabilities,
5
+ and the KL divergence is the difference between those two averages. Minimizing a negative
6
+ log-likelihood over a model is therefore minimizing a KL divergence to the data distribution.
7
+
8
+ Chapter 1, Sections 1.33, 1.34 and 1.35 of the notes. Runs on the pair p = (0.8, 0.2) and
9
+ q = (0.6, 0.4) of Section 1.33.3 and on the ten DNA bases of Section 1.35. Prints the chapter's
10
+ number beside the computed one. Logarithms are natural, so every quantity is in nats.
11
+
12
+ python lecture_1/information.py # every worked example
13
+ python lecture_1/information.py --samples 200000 # a tighter Monte Carlo cross-entropy
14
+ python lecture_1/information.py --quiet # the closing report only
15
+ """
16
+
17
+ # %% 01. Imports and configuration
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import math
22
+
23
+ import torch
24
+
25
+ from cis6270.runner import print_report, save_run, set_seed
26
+
27
+ torch.set_default_dtype(torch.float64) # Chapter 1 is arithmetic, and five printed digits need it.
28
+
29
+
30
+ P_REF = torch.tensor([0.8, 0.2]) # The reference distribution p of Sections 1.33.3 and 1.34.
31
+ Q_MODEL = torch.tensor([0.6, 0.4]) # The comparison distribution q scored against those samples.
32
+ BASE_COUNTS = torch.tensor([4.0, 3.0, 2.0, 1.0]) # n(A), n(C), n(G), n(T) over ten DNA bases.
33
+
34
+
35
+ WORKED: dict[str, float] = {} # Every computed value, by label, for tests/test_lecture_1.py.
36
+
37
+
38
+ def show(label: str, by_hand: float, computed: float, quiet: bool = False) -> None:
39
+ """Print the chapter's hand-worked value beside the value this file computes, and keep it."""
40
+ WORKED[label] = computed # The test suite asserts these against the chapter's own numbers.
41
+ if not quiet: # Under --quiet only the closing report is printed.
42
+ computed = computed + 0.0 # Adding zero prints a negative zero as a plain zero.
43
+ print(f" {label:<36s} chapter {by_hand:>11.5f} computed {computed:>11.5f}"
44
+ f" gap {abs(by_hand - computed):.1e}")
45
+
46
+
47
+ def entropy(p: torch.Tensor) -> float:
48
+ """H(p) = -sum_x p(x) log p(x), the expected information in one sample drawn from p."""
49
+ return float(-(p * p.clamp_min(1e-300).log()).sum()) # Clamp keeps 0 log 0 at zero.
50
+
51
+
52
+ def cross_entropy(p: torch.Tensor, q: torch.Tensor) -> float:
53
+ """H(p, q) = -sum_x p(x) log q(x), the cost of scoring samples from p with the values of q."""
54
+ return float(-(p * q.log()).sum()) # The weights come from p and the probabilities from q.
55
+
56
+
57
+ def divergence(p: torch.Tensor, q: torch.Tensor) -> float:
58
+ """KL(p || q) = sum_x p(x) log(p(x) / q(x)), the expected log ratio taken under p."""
59
+ return float((p * (p / q).log()).sum()) # Zero exactly when q matches p at every state.
60
+
61
+
62
+ # %% 02. Information content
63
+ def information(quiet: bool = False) -> dict:
64
+ """Section 1.33.1: I(x) = -log p(x), so a rarer outcome carries more information."""
65
+ common = -math.log(0.5) # An outcome of probability one half.
66
+ rare = -math.log(0.01) # An outcome fifty times less likely.
67
+ certain = -math.log(1.0) # A certain event, which has zero information.
68
+ show("I(x) at p(x) = 0.5", 0.693, common, quiet)
69
+ show("I(x) at p(x) = 0.01", 4.605, rare, quiet)
70
+ show("I(x) at p(x) = 1", 0.0, certain, quiet)
71
+ show("ratio of the two surprises", 6.6, rare / common, quiet)
72
+ return {"common": common, "rare": rare}
73
+
74
+
75
+ # %% 03. Entropy
76
+ def entropies(quiet: bool = False) -> dict:
77
+ """Section 1.33.2: entropy is high when the mass is spread and zero when it is all on one state."""
78
+ skewed = entropy(torch.tensor([0.9, 0.1])) # 0.09482 + 0.23026 nats.
79
+ balanced = entropy(torch.tensor([0.5, 0.5])) # log 2, the largest value on two states.
80
+ deterministic = entropy(torch.tensor([1.0, 0.0])) # All mass on one state, so 1 log 1 = 0.
81
+ uniform_four = entropy(torch.full((4,), 0.25)) # log K at K = 4, proved in Section 1.37.2.
82
+ show("H((0.9, 0.1))", 0.325, skewed, quiet)
83
+ show("H((0.5, 0.5)) = log 2", 0.693, balanced, quiet)
84
+ show("H of a point mass", 0.0, deterministic, quiet)
85
+ show("H of the uniform on 4 states", 1.386, uniform_four, quiet)
86
+ return {"skewed": skewed, "balanced": balanced, "uniform_four": uniform_four}
87
+
88
+
89
+ # %% 04. Cross-entropy
90
+ def cross_entropies(quiet: bool = False) -> dict:
91
+ """Section 1.33.3: scoring samples from p with the probabilities of q costs more than H(p)."""
92
+ cost = cross_entropy(P_REF, Q_MODEL) # 0.40866 + 0.18326 nats.
93
+ own = entropy(P_REF) # 0.17851 + 0.32189 nats, the irreducible part of that cost.
94
+ same = cross_entropy(P_REF, P_REF) # H(p, p) recovers the entropy exactly.
95
+ show("H(p, q)", 0.592, cost, quiet)
96
+ show("H(p)", 0.500, own, quiet)
97
+ show("excess cost H(p, q) - H(p)", 0.092, cost - own, quiet)
98
+ show("H(p, p) - H(p)", 0.0, same - own, quiet)
99
+ return {"cost": cost, "own": own, "excess": cost - own}
100
+
101
+
102
+ # %% 05. Kullback-Leibler divergence
103
+ def kl(samples: int, quiet: bool = False) -> dict:
104
+ """Section 1.34: the split H(p, q) = H(p) + KL(p || q), nonnegativity, and the asymmetry."""
105
+ forward = divergence(P_REF, Q_MODEL) # 0.23015 - 0.13863 nats, the price of using q.
106
+ reverse = divergence(Q_MODEL, P_REF) # -0.17261 + 0.27726 nats, the other direction.
107
+ split = entropy(P_REF) + forward # The boxed identity, which must return H(p, q).
108
+ logits = torch.randn(max(samples, 1), 2, 8) # Random logits for pairs of distributions.
109
+ pairs = torch.softmax(logits, dim=-1) # Two distributions over eight states per row; [N, 2, 8].
110
+ drawn = (pairs[:, 0] * (pairs[:, 0] / pairs[:, 1]).log()).sum(-1) # KL for every pair; [N].
111
+ violation = float((-drawn).clamp_min(0.0).max()) # The largest breach of KL >= 0 among them.
112
+ t = torch.linspace(0.01, 5.0, 500) # The range on which the inequality log t <= t - 1 is checked.
113
+ show("KL(p || q)", 0.0915, forward, quiet)
114
+ show("KL(q || p)", 0.1046, reverse, quiet)
115
+ show("H(p) + KL(p || q) = H(p, q)", 0.592, split, quiet)
116
+ show("KL(p || p)", 0.0, divergence(P_REF, P_REF), quiet)
117
+ show("reverse over forward, percent", 14.0, 100.0 * (reverse / forward - 1.0), quiet)
118
+ show("largest log t - (t - 1)", 0.0, float((t.log() - (t - 1.0)).max()), quiet)
119
+ show(f"largest KL breach over {len(drawn)} pairs", 0.0, violation, quiet)
120
+ return {"forward": forward, "reverse": reverse, "split": split,
121
+ "smallest": float(drawn.min()), "violation": violation}
122
+
123
+
124
+ # %% 06. Cross-entropy by Monte Carlo
125
+ def monte_carlo(samples: int, repeats: int, quiet: bool = False) -> dict:
126
+ """Section 1.5.3 applied to Section 1.33.3: H(p, q) is an expectation, so an average estimates it.
127
+
128
+ We draw states from p and average -log q at the drawn states. The estimate moves with --seed
129
+ and with --samples, and the exact value it approaches does not. Repeating a ten-sample estimate
130
+ ``repeats`` times measures the sd that Section 1.5.3 predicts for a ten-sample average.
131
+ """
132
+ exact = cross_entropy(P_REF, Q_MODEL) # The target of the estimator, 0.592 nats.
133
+ draws = torch.multinomial(P_REF, max(samples, 10), replacement=True) # [N] states drawn from p.
134
+ surprise = -Q_MODEL.log() # The two values -log q(x) the estimator averages over; [2].
135
+ scored = surprise[draws] # The summand -log q(x^(n)) at each drawn state; [N].
136
+ estimate, spread = float(scored.mean()), float(scored.std()) # The average and its per-sample sd.
137
+ error = spread / math.sqrt(len(draws)) # The sd of the average, which falls like 1/sqrt(N).
138
+ show("H(p, q) exactly", 0.592, exact, quiet)
139
+ if not quiet:
140
+ label = f"H(p, q) from {len(draws)} samples"
141
+ print(f" {label:<36s} exact {exact:>11.5f} estimate {estimate:>11.5f}"
142
+ f" sd {error:.1e}")
143
+ rounds = max(repeats, 2) # Two averages is the fewest a standard deviation can be taken over.
144
+ repeated = torch.multinomial(P_REF, 10 * rounds, replacement=True) # [10 * rounds] states.
145
+ averages = surprise[repeated].view(rounds, 10).mean(dim=1) # One ten-sample estimate per round.
146
+ exact_spread = float((P_REF @ (surprise - exact).square()).sqrt()) # sd of one scored draw.
147
+ predicted = exact_spread / math.sqrt(10.0) # The sd of a ten-sample average, Section 1.5.3.
148
+ show(f"sd of {rounds} ten-sample estimates", predicted, float(averages.std()), quiet)
149
+ return {"exact": exact, "estimate": estimate, "error": error, "draws": len(draws),
150
+ "predicted": predicted, "measured": float(averages.std()), "rounds": rounds}
151
+
152
+
153
+ # %% 07. Maximum likelihood as KL minimization
154
+ def maximum_likelihood(quiet: bool = False) -> dict:
155
+ """Section 1.35: the empirical loss at the frequencies is their entropy, and the gap to any
156
+ other model is the KL divergence from the frequencies to it."""
157
+ frequencies = BASE_COUNTS / BASE_COUNTS.sum() # p_hat = (0.4, 0.3, 0.2, 0.1) over A, C, G, T.
158
+ uniform = torch.full((4,), 0.25) # The categorical model that spreads the mass evenly.
159
+ at_frequencies = cross_entropy(frequencies, frequencies) # The empirical loss at p_hat.
160
+ at_uniform = cross_entropy(frequencies, uniform) # The same data scored by the uniform model.
161
+ gap = at_uniform - at_frequencies # The reducible part of the cost, which is a KL divergence.
162
+ terms = -frequencies * frequencies.log() # The four terms the chapter adds up; [4].
163
+ show("term for A, 0.4 log(1/0.4)", 0.36652, float(terms[0]), quiet)
164
+ show("term for C, 0.3 log(1/0.3)", 0.36119, float(terms[1]), quiet)
165
+ show("term for G, 0.2 log(1/0.2)", 0.32189, float(terms[2]), quiet)
166
+ show("term for T, 0.1 log(1/0.1)", 0.23026, float(terms[3]), quiet)
167
+ show("loss at the frequencies", 1.280, at_frequencies, quiet)
168
+ show("loss under the uniform model", 1.386, at_uniform, quiet)
169
+ show("gap between the two losses", 0.1064, gap, quiet)
170
+ show("KL(p_hat || uniform)", 0.1064, divergence(frequencies, uniform), quiet)
171
+ return {"frequencies": frequencies, "at_frequencies": at_frequencies,
172
+ "at_uniform": at_uniform, "gap": gap}
173
+
174
+
175
+ # %% 08. The report
176
+ def main() -> None:
177
+ parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
178
+ parser.add_argument("--samples", type=int, default=20000, help="draws in the Monte Carlo checks")
179
+ parser.add_argument("--steps", type=int, default=5000, help="repeats of the ten-sample estimate")
180
+ parser.add_argument("--seed", type=int, default=0, help="random seed for the Monte Carlo draws")
181
+ parser.add_argument("--out", type=str, default="", help="directory for the run's output")
182
+ parser.add_argument("--quiet", action="store_true", help="print only the final report")
183
+ args = parser.parse_args()
184
+
185
+ set_seed(args.seed) # Seed first, so the same seed gives the same draws.
186
+ if not args.quiet:
187
+ print("Chapter 1, Sections 1.33 to 1.35, worked example by worked example\n")
188
+ surprise = information(args.quiet)
189
+ spread = entropies(args.quiet)
190
+ cost = cross_entropies(args.quiet)
191
+ gap = kl(args.samples, args.quiet)
192
+ estimate = monte_carlo(args.samples, args.steps, args.quiet)
193
+ fitted = maximum_likelihood(args.quiet)
194
+
195
+ report = {
196
+ "I(x) at p(x) = 0.01, nats": surprise["rare"],
197
+ "H((0.9, 0.1)), nats": spread["skewed"],
198
+ "H((0.5, 0.5)), nats": spread["balanced"],
199
+ "H(p), nats": cost["own"],
200
+ "H(p, q), nats": cost["cost"],
201
+ "KL(p || q), nats": gap["forward"],
202
+ "KL(q || p), nats": gap["reverse"],
203
+ "smallest KL over random pairs": gap["smallest"],
204
+ "largest KL breach, random pairs": gap["violation"],
205
+ "H(p, q) from samples": estimate["estimate"],
206
+ "Monte Carlo samples drawn": estimate["draws"],
207
+ "ten-sample estimates taken": estimate["rounds"],
208
+ "sd of those estimates": estimate["measured"],
209
+ "sd Section 1.5.3 predicts": estimate["predicted"],
210
+ "empirical loss at p_hat, nats": fitted["at_frequencies"],
211
+ "loss under the uniform, nats": fitted["at_uniform"],
212
+ "KL(p_hat || uniform), nats": fitted["gap"],
213
+ }
214
+ print_report("Information, Sections 1.33 to 1.35", report)
215
+ save_run(args.out, config=vars(args), report=report)
216
+
217
+
218
+ if __name__ == "__main__": # Execute when launched directly.
219
+ main() # Work every example of Sections 1.33 to 1.35 and print the report.
lecture_1/linear_algebra.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The simplex, the softmax, transition matrices and the matrix exponential.
2
+
3
+ A distribution over K states is a point of the simplex, a softmax maps unrestricted logits onto
4
+ that simplex, and a row-stochastic matrix moves one point of it to the next. Along an
5
+ eigendirection a matrix power is a power of a number, and that is where the stationary
6
+ distribution, the rate at which a chain reaches it, and the matrix exponential of continuous time
7
+ all come from.
8
+
9
+ Chapter 1, Sections 1.12, 1.13, 1.15, 1.16, 1.17 and 1.18 of the notes. Runs on the three-base
10
+ simplex of Section 1.12.2, the two-state chain of Section 1.15.1 and the symmetric matrix
11
+ A = [[2, 1], [1, 2]] of Section 1.16. Prints the chapter's number beside the computed one.
12
+
13
+ python lecture_1/linear_algebra.py # every worked example
14
+ python lecture_1/linear_algebra.py --steps 40 # run the chain further toward p*
15
+ python lecture_1/linear_algebra.py --quiet # the closing report only
16
+ """
17
+
18
+ # %% 01. Imports and configuration
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import math
23
+
24
+ import torch
25
+
26
+ from cis6270.runner import print_report, save_run, set_seed
27
+
28
+ torch.set_default_dtype(torch.float64) # Chapter 1 is arithmetic, and five printed digits need it.
29
+
30
+
31
+ P_BASES = torch.tensor([0.5, 0.3, 0.2]) # The three-state distribution over A, C, G; Section 1.12.2.
32
+ H_BASES = torch.tensor([1.0, 0.0, 4.0]) # Hydrogen-bond contacts per base, the property h.
33
+ Q_BASES = torch.tensor([0.1, 0.1, 0.8]) # The second point of the simplex, mixed with P_BASES.
34
+ LOGITS = torch.tensor([2.0, 1.0, 0.0]) # The logit vector z of Section 1.13.1.
35
+ P_CHAIN = torch.tensor([[0.8, 0.2], # Row 1: stay in state one with 0.8, move to state two with 0.2.
36
+ [0.3, 0.7]]) # Row 2: move to state one with 0.3, stay with 0.7.
37
+ A_MATRIX = torch.tensor([[2.0, 1.0], [1.0, 2.0]]) # The running matrix of Sections 1.16 and 1.18.
38
+ X_START = torch.tensor([3.0, 1.0]) # The initial vector x_0 = 2 v_1 + v_2 of Section 1.16.2.
39
+
40
+
41
+ WORKED: dict[str, float] = {} # Every computed value, by label, for tests/test_lecture_1.py.
42
+
43
+
44
+ def show(label: str, by_hand: float, computed: float, quiet: bool = False) -> None:
45
+ """Print the chapter's hand-worked value beside the value this file computes, and keep it."""
46
+ WORKED[label] = computed # The test suite asserts these against the chapter's own numbers.
47
+ if not quiet: # Under --quiet only the closing report is printed.
48
+ computed = computed + 0.0 # Adding zero prints a negative zero as a plain zero.
49
+ print(f" {label:<36s} chapter {by_hand:>11.5f} computed {computed:>11.5f}"
50
+ f" gap {abs(by_hand - computed):.1e}")
51
+
52
+
53
+ # %% 02. Probability vectors and the simplex
54
+ def simplex(quiet: bool = False) -> dict:
55
+ """Sections 1.12.2 and 1.12.3: expectation as a dot product, and a mixture of two points."""
56
+ expectation = float(P_BASES @ H_BASES) # E[h(X)] = p^T h = 0.5 + 0 + 0.8 = 1.3.
57
+ mixture = 0.5 * P_BASES + 0.5 * Q_BASES # r = lambda p + (1 - lambda) q at lambda = 0.5; [3].
58
+ corners = 0.3 * torch.tensor([1.0, 0.0]) + 0.7 * torch.tensor([0.0, 1.0]) # Two-state mixture.
59
+ show("sum_i p_i", 1.0, float(P_BASES.sum()), quiet)
60
+ show("E[h(X)] = p^T h", 1.3, expectation, quiet)
61
+ show("mixture r_1 at lambda = 0.5", 0.30, float(mixture[0]), quiet)
62
+ show("mixture r_2 at lambda = 0.5", 0.20, float(mixture[1]), quiet)
63
+ show("mixture r_3 at lambda = 0.5", 0.50, float(mixture[2]), quiet)
64
+ show("sum_i r_i, still a distribution", 1.0, float(mixture.sum()), quiet)
65
+ show("0.3 e_1 + 0.7 e_2, first entry", 0.3, float(corners[0]), quiet)
66
+ return {"expectation": expectation, "mixture": mixture}
67
+
68
+
69
+ # %% 03. Logits and the softmax
70
+ def softmax(quiet: bool = False) -> dict:
71
+ """Section 1.13.1: softmax(z)_i = e^{z_i} / sum_j e^{z_j} on the logits z = (2, 1, 0)."""
72
+ exponentials = LOGITS.exp() # e^2, e^1, e^0; [3].
73
+ partition = float(exponentials.sum()) # The normalizing sum, 7.389 + 2.718 + 1.000.
74
+ probabilities = exponentials / partition # The point of the simplex the logits name; [3].
75
+ shifted = torch.softmax(LOGITS + 5.0, dim=0) # Adding a constant c cancels in the ratio.
76
+ show("e^{z_1}", 7.389, float(exponentials[0]), quiet)
77
+ show("e^{z_2}", 2.718, float(exponentials[1]), quiet)
78
+ show("e^{z_3}", 1.000, float(exponentials[2]), quiet)
79
+ show("partition function sum_j e^{z_j}", 11.107, partition, quiet)
80
+ show("softmax(z)_1", 0.665, float(probabilities[0]), quiet)
81
+ show("softmax(z)_2", 0.245, float(probabilities[1]), quiet)
82
+ show("softmax(z)_3", 0.090, float(probabilities[2]), quiet)
83
+ show("sum_i softmax(z)_i", 1.0, float(probabilities.sum()), quiet)
84
+ show("shift invariance, largest change", 0.0, float((shifted - probabilities).abs().max()), quiet)
85
+ return {"probabilities": probabilities, "partition": partition}
86
+
87
+
88
+ # %% 04. A probability vector under a transition matrix
89
+ def one_transition(quiet: bool = False) -> dict:
90
+ """Section 1.15.1: p_{t+1} = P^T p_t moves an even split to (0.55, 0.45) in one step."""
91
+ p_t = torch.tensor([0.5, 0.5]) # The current distribution over the two states; [2].
92
+ p_next = P_CHAIN.T @ p_t # Each new entry collects the mass arriving from every state; [2].
93
+ show("row sums of P, row 1", 1.0, float(P_CHAIN[0].sum()), quiet)
94
+ show("row sums of P, row 2", 1.0, float(P_CHAIN[1].sum()), quiet)
95
+ show("p_{t+1}(1)", 0.55, float(p_next[0]), quiet)
96
+ show("p_{t+1}(2)", 0.45, float(p_next[1]), quiet)
97
+ show("sum_j p_{t+1}(j)", 1.0, float(p_next.sum()), quiet)
98
+ return {"p_next": p_next}
99
+
100
+
101
+ # %% 05. Eigenvalues and eigenvectors
102
+ def eigen(quiet: bool = False) -> dict:
103
+ """Section 1.16: the characteristic equation of A, its two eigenvalues and its eigenvectors."""
104
+ trace = float(A_MATRIX.trace()) # The sum of the eigenvalues, 4.
105
+ determinant = float(torch.linalg.det(A_MATRIX)) # The product of the eigenvalues, 3.
106
+ values, vectors = torch.linalg.eigh(A_MATRIX) # A is symmetric, so the eigenvalues are real.
107
+ order = values.argsort(descending=True) # The chapter indexes lambda_1 = 3 before lambda_2 = 1.
108
+ values, vectors = values[order], vectors[:, order] # [2] and [2, 2].
109
+ vectors = vectors / vectors[0] # Rescale each one to first entry one, which here gives (1, ±1).
110
+ residual = (A_MATRIX @ vectors - vectors * values).abs().max() # A v - lambda v at both pairs.
111
+ show("trace A, lambda_1 + lambda_2", 4.0, trace, quiet)
112
+ show("det A, lambda_1 lambda_2", 3.0, determinant, quiet)
113
+ show("lambda_1", 3.0, float(values[0]), quiet)
114
+ show("lambda_2", 1.0, float(values[1]), quiet)
115
+ show("v_1 second entry over first", 1.0, float(vectors[1, 0]), quiet)
116
+ show("v_2 second entry over first", -1.0, float(vectors[1, 1]), quiet)
117
+ show("largest |A v - lambda v|", 0.0, float(residual), quiet)
118
+ return {"values": values, "vectors": vectors}
119
+
120
+
121
+ # %% 06. Powers along an eigendirection
122
+ def powers(values: torch.Tensor, vectors: torch.Tensor, quiet: bool = False) -> dict:
123
+ """Section 1.16.2: x_n = c_1 lambda_1^n v_1 + c_2 lambda_2^n v_2, against A^n x_0 taken directly."""
124
+ coefficients = torch.linalg.solve(vectors, X_START) # c_1 = 2 and c_2 = 1 for x_0 = (3, 1).
125
+ eigen_route = vectors @ (coefficients * values ** 2) # The n = 2 state along the two directions.
126
+ direct = torch.linalg.matrix_power(A_MATRIX, 2) @ X_START # A^2 x_0, squaring the matrix first.
127
+ far = vectors @ (coefficients * values ** 10) # The same decomposition carried to n = 10.
128
+ show("c_1", 2.0, float(coefficients[0]), quiet)
129
+ show("c_2", 1.0, float(coefficients[1]), quiet)
130
+ show("lambda_1^10", 59049.0, float(values[0]) ** 10, quiet)
131
+ show("x_2 first entry, eigen route", 19.0, float(eigen_route[0]), quiet)
132
+ show("x_2 second entry, eigen route", 17.0, float(eigen_route[1]), quiet)
133
+ show("x_2 first entry, A^2 x_0", 19.0, float(direct[0]), quiet)
134
+ show("x_10 first entry", 118099.0, float(far[0]), quiet)
135
+ show("x_10 second entry", 118097.0, float(far[1]), quiet)
136
+ return {"x_two": eigen_route, "x_ten": far}
137
+
138
+
139
+ # %% 07. Diagonalization and the dominant eigenvalue
140
+ def diagonalization(values: torch.Tensor, vectors: torch.Tensor, quiet: bool = False) -> dict:
141
+ """Sections 1.16.3 and 1.16.4: A = V Lambda V^{-1}, so A^n = V Lambda^n V^{-1} telescopes."""
142
+ inverse = torch.linalg.inv(vectors) # V^{-1} = 0.5 [[1, 1], [1, -1]]; [2, 2].
143
+ rebuilt = vectors @ torch.diag(values) @ inverse # V Lambda V^{-1} returns A itself; [2, 2].
144
+ cube = vectors @ torch.diag(values ** 3) @ inverse # V diag(27, 1) V^{-1}, the n = 3 power.
145
+ direct_cube = torch.linalg.matrix_power(A_MATRIX, 3) # A^3 computed by repeated multiplication.
146
+ dominant = torch.tensor([2.0, 0.5]) ** 10 # The coefficients 2^10 and 0.5^10 of Section 1.16.4.
147
+ show("V^{-1}_11", 0.5, float(inverse[0, 0]), quiet)
148
+ show("largest |V Lambda V^{-1} - A|", 0.0, float((rebuilt - A_MATRIX).abs().max()), quiet)
149
+ show("A^3_11 through the eigenvalues", 14.0, float(cube[0, 0]), quiet)
150
+ show("A^3_12 through the eigenvalues", 13.0, float(cube[0, 1]), quiet)
151
+ show("largest |V Lambda^3 V^{-1} - A^3|", 0.0, float((cube - direct_cube).abs().max()), quiet)
152
+ show("dominant coefficient 2^10", 1024.0, float(dominant[0]), quiet)
153
+ show("decaying coefficient 0.5^10", 0.000977, float(dominant[1]), quiet)
154
+ show("ratio of the two, in millions", 1.05, float(dominant[0] / dominant[1]) / 1e6, quiet)
155
+ return {"inverse": inverse, "cube": cube}
156
+
157
+
158
+ # %% 08. Stationary distributions
159
+ def stationary(steps: int, quiet: bool = False) -> dict:
160
+ """Section 1.17: p* solves (P^T - I) p* = 0 with sum one, and the chain halves its deviation."""
161
+ drift = (P_CHAIN.T - torch.eye(2))[0] # The first row of P^T - I, which reads 0.3 p_2 = 0.2 p_1.
162
+ system = torch.stack([drift, torch.ones(2)]) # That one equation, with normalization; [2, 2].
163
+ p_star = torch.linalg.solve(system, torch.tensor([0.0, 1.0])) # The lambda = 1 eigenvector; [2].
164
+ trace, determinant = float(P_CHAIN.trace()), float(torch.linalg.det(P_CHAIN)) # 1.5 and 0.5.
165
+ second = (trace - math.sqrt(trace ** 2 - 4.0 * determinant)) / 2.0 # The smaller root, 0.5.
166
+ p_t, history = torch.tensor([0.5, 0.5]), [] # Start from the even split of Section 1.15.1.
167
+ for _ in range(max(steps, 1)): # Iterate p_{t+1} = P^T p_t for as many steps as requested.
168
+ p_t = P_CHAIN.T @ p_t # One more application of the transition matrix; [2].
169
+ history.append(p_t.clone())
170
+ show("p*_1", 0.6, float(p_star[0]), quiet)
171
+ show("p*_2", 0.4, float(p_star[1]), quiet)
172
+ show("largest |P^T p* - p*|", 0.0, float((P_CHAIN.T @ p_star - p_star).abs().max()), quiet)
173
+ show("trace P", 1.5, trace, quiet)
174
+ show("det P", 0.5, determinant, quiet)
175
+ show("second eigenvalue, the rate", 0.5, second, quiet)
176
+ for index, value in ((0, 0.55), (1, 0.575), (2, 0.5875), (9, 0.59990)):
177
+ if index < len(history): # The chapter prints p_1, p_2, p_3 and p_10 of this sequence.
178
+ show(f"p_{index + 1}(1)", value, float(history[index][0]), quiet)
179
+ deviation = float((history[-1] - p_star).abs().max()) # |d_n| = 0.5^n |d_0| after n steps.
180
+ show(f"|d_n| after {len(history)} steps", 0.1 * second ** len(history), deviation, quiet)
181
+ return {"p_star": p_star, "second": second, "history": history, "deviation": deviation}
182
+
183
+
184
+ # %% 09. From discrete steps to continuous time
185
+ def matrix_exponential(values: torch.Tensor, vectors: torch.Tensor, quiet: bool = False) -> dict:
186
+ """Section 1.18.3: the four-term series and the eigenvalue formula for e^{tA} at t = 0.1."""
187
+ t = 0.1 # The small time at which the chapter truncates the series.
188
+ series, power, factorial = torch.eye(2), torch.eye(2), 1.0 # Accumulate I + tA + t^2A^2/2 + ...
189
+ for k in range(1, 4): # Terms k = 1, 2, 3, which with the identity make four.
190
+ power, factorial = power @ A_MATRIX, factorial * k # A^k and k factorial.
191
+ series = series + t ** k * power / factorial # Add t^k A^k / k!; [2, 2].
192
+ discarded = t ** 4 * (power @ A_MATRIX) / 24.0 # The first omitted term, t^4 A^4 / 4!.
193
+ scaled = torch.diag((t * values).exp()) # e^{t Lambda}, the diagonal of entry exponentials; [2, 2].
194
+ eigen_route = vectors @ scaled @ torch.linalg.inv(vectors) # V e^{t Lambda} V^{-1}; [2, 2].
195
+ gap = float((series - eigen_route).abs().max()) # How far four terms fall short at t = 0.1.
196
+ coefficients = torch.linalg.solve(vectors, X_START) # c_1 = 2, c_2 = 1, as in Section 1.16.2.
197
+ x_one = vectors @ (coefficients * values.exp()) # x_t = sum_i c_i e^{t lambda_i} v_i at t = 1.
198
+ show("four-term series, entry 11", 1.22733, float(series[0, 0]), quiet)
199
+ show("four-term series, entry 12", 0.12217, float(series[0, 1]), quiet)
200
+ show("eigenvalue route, entry 11", 1.22751, float(eigen_route[0, 0]), quiet)
201
+ show("eigenvalue route, entry 12", 0.12234, float(eigen_route[0, 1]), quiet)
202
+ show("largest gap between the routes", 1.8e-4, gap, quiet)
203
+ show("first discarded term, entry 11", 1.71e-4, float(discarded[0, 0]), quiet)
204
+ show("first discarded term, entry 12", 1.67e-4, float(discarded[0, 1]), quiet)
205
+ show("x_1 first entry, 2e^3 + e", 42.889, float(x_one[0]), quiet)
206
+ show("x_1 second entry, 2e^3 - e", 37.453, float(x_one[1]), quiet)
207
+ show("decay e^{-2t} at t = 1", 0.1353, math.exp(-2.0), quiet)
208
+ show("decay e^{-2t} at t = 3", 0.002479, math.exp(-6.0), quiet)
209
+ show("sqrt(lambda_1), Gaussian long axis", 1.732, math.sqrt(float(values[0])), quiet)
210
+ return {"series": series, "eigen_route": eigen_route, "gap": gap, "x_one": x_one}
211
+
212
+
213
+ # %% 10. The report
214
+ def main() -> None:
215
+ parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
216
+ parser.add_argument("--steps", type=int, default=10, help="iterations of p_{t+1} = P^T p_t")
217
+ parser.add_argument("--seed", type=int, default=0, help="random seed, for a repeatable run")
218
+ parser.add_argument("--out", type=str, default="", help="directory for the run's output")
219
+ parser.add_argument("--quiet", action="store_true", help="print only the final report")
220
+ args = parser.parse_args()
221
+
222
+ set_seed(args.seed) # Seed first, so the same seed gives the same run.
223
+ if not args.quiet:
224
+ print("Chapter 1, Sections 1.12 to 1.18, worked example by worked example\n")
225
+ triangle = simplex(args.quiet)
226
+ logits = softmax(args.quiet)
227
+ step = one_transition(args.quiet)
228
+ spectrum = eigen(args.quiet)
229
+ grown = powers(spectrum["values"], spectrum["vectors"], args.quiet)
230
+ diagonalization(spectrum["values"], spectrum["vectors"], args.quiet)
231
+ chain = stationary(args.steps, args.quiet)
232
+ flow = matrix_exponential(spectrum["values"], spectrum["vectors"], args.quiet)
233
+
234
+ report = {
235
+ "E[h(X)] on the three bases": triangle["expectation"],
236
+ "softmax(z)_1": float(logits["probabilities"][0]),
237
+ "p_{t+1}(1) after one step": float(step["p_next"][0]),
238
+ "lambda_1 of A": float(spectrum["values"][0]),
239
+ "lambda_2 of A": float(spectrum["values"][1]),
240
+ "x_10 first entry": float(grown["x_ten"][0]),
241
+ "p*_1, stationary": float(chain["p_star"][0]),
242
+ "second eigenvalue of P": chain["second"],
243
+ "chain steps run": len(chain["history"]),
244
+ "deviation from p* at the end": chain["deviation"],
245
+ "e^{tA}_11 at t = 0.1": float(flow["eigen_route"][0, 0]),
246
+ "four-term truncation error": flow["gap"],
247
+ }
248
+ print_report("Linear algebra, Sections 1.12 to 1.18", report)
249
+ save_run(args.out, config=vars(args), report=report)
250
+
251
+
252
+ if __name__ == "__main__": # Execute when launched directly.
253
+ main() # Work every example of Sections 1.12 to 1.18 and print the report.
lecture_1/optimization.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unconstrained descent, Lagrange multipliers, maximum entropy and the KKT conditions.
2
+
3
+ A differentiable minimum has a zero gradient, and a descent step moves against that gradient. An
4
+ equality constraint comes with a multiplier, and at the constrained optimum the two gradients are
5
+ parallel. Applied to entropy under an expectation constraint, that condition leads to an
6
+ exponential family. An inequality comes with a sign requirement and complementary slackness, and
7
+ under the softmax the simplex constraints hold by construction.
8
+
9
+ Chapter 1, Sections 1.36, 1.37 and 1.38 of the notes. Runs on the quadratic loss of
10
+ Section 1.36, the constrained distance of Section 1.37.1, the GC-content target of
11
+ Section 1.37.3 and the bounded quadratic of Section 1.38.
12
+
13
+ python lecture_1/optimization.py # every worked example
14
+ python lecture_1/optimization.py --steps 20 # a short descent, far from the minimizer
15
+ python lecture_1/optimization.py --quiet # the closing report only
16
+ """
17
+
18
+ # %% 01. Imports and configuration
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import math
23
+
24
+ import torch
25
+
26
+ from cis6270.runner import print_report, save_run, set_seed
27
+
28
+ torch.set_default_dtype(torch.float64) # Chapter 1 is arithmetic, and five printed digits need it.
29
+
30
+
31
+ BASE_COUNTS = torch.tensor([4.0, 3.0, 2.0, 1.0]) # n(A), n(C), n(G), n(T) over ten DNA bases.
32
+ GC_INDICATOR = torch.tensor([0.0, 1.0, 1.0, 0.0]) # h(x) = 1 on the strong bases C and G.
33
+ GC_TARGET = 0.6 # The required expected GC content, the constraint value c of Section 1.37.3.
34
+
35
+
36
+ WORKED: dict[str, float] = {} # Every computed value, by label, for tests/test_lecture_1.py.
37
+
38
+
39
+ def show(label: str, by_hand: float, computed: float, quiet: bool = False) -> None:
40
+ """Print the chapter's hand-worked value beside the value this file computes, and keep it."""
41
+ WORKED[label] = computed # The test suite asserts these against the chapter's own numbers.
42
+ if not quiet: # Under --quiet only the closing report is printed.
43
+ computed = computed + 0.0 # Adding zero prints a negative zero as a plain zero.
44
+ print(f" {label:<36s} chapter {by_hand:>11.5f} computed {computed:>11.5f}"
45
+ f" gap {abs(by_hand - computed):.1e}")
46
+
47
+
48
+ def entropy(p: torch.Tensor) -> float:
49
+ """H(p) = -sum_i p_i log p_i, the quantity maximized in both constrained problems below."""
50
+ return float(-(p * p.clamp_min(1e-300).log()).sum()) # Clamp keeps 0 log 0 at zero.
51
+
52
+
53
+ # %% 02. Optimization without constraints
54
+ def unconstrained(steps: int, quiet: bool = False) -> dict:
55
+ """Section 1.36: on L(theta) = (theta - 3)^2 the error contracts by 0.8 at every step."""
56
+ learning_rate, theta = 0.1, 0.0 # The step size eta and the starting parameter theta_0.
57
+ history = [] # The iterates, so the first three can be compared with the chapter.
58
+ for _ in range(max(steps, 1)):
59
+ theta = theta - learning_rate * 2.0 * (theta - 3.0) # theta - eta L'(theta) = 0.8 theta + 0.6.
60
+ history.append(theta)
61
+ closed_form = 3.0 - 3.0 * 0.8 ** len(history) # theta_k = 3 - 3(0.8)^k, from the contraction.
62
+ for index, value in ((0, 0.600), (1, 1.080), (2, 1.464)):
63
+ if index < len(history): # The chapter prints theta_1, theta_2 and theta_3.
64
+ show(f"theta_{index + 1}", value, history[index], quiet)
65
+ show(f"theta_{len(history)} from the closed form", closed_form, history[-1], quiet)
66
+ show("gradient at the minimizer theta = 3", 0.0, 2.0 * (3.0 - 3.0), quiet)
67
+ return {"history": history, "closed_form": closed_form}
68
+
69
+
70
+ # %% 03. An equality constraint and its multiplier
71
+ def equality_constraint(quiet: bool = False) -> dict:
72
+ """Section 1.37.1: minimize x_1^2 + x_2^2 on the line x_1 + x_2 = 1, through the Lagrangian."""
73
+ system = torch.tensor([[2.0, 0.0, 1.0], # 2 x_1 + lambda = 0, the first stationarity condition.
74
+ [0.0, 2.0, 1.0], # 2 x_2 + lambda = 0, the second.
75
+ [1.0, 1.0, 0.0]]) # x_1 + x_2 = 1, the constraint itself.
76
+ solution = torch.linalg.solve(system, torch.tensor([0.0, 0.0, 1.0])) # (x_1, x_2, lambda); [3].
77
+ optimum = float(solution[:2].square().sum()) # f at the constrained minimizer.
78
+ endpoint = float(torch.tensor([1.0, 0.0]).square().sum()) # f at either end of the line.
79
+ grad_f = 2.0 * solution[:2] # grad f = (2 x_1, 2 x_2) at the solution; [2].
80
+ parallel = float((grad_f + solution[2] * torch.ones(2)).abs().max()) # grad f + lambda grad g.
81
+ show("x_1 at the constrained minimum", 0.5, float(solution[0]), quiet)
82
+ show("x_2 at the constrained minimum", 0.5, float(solution[1]), quiet)
83
+ show("lambda, the equality multiplier", -1.0, float(solution[2]), quiet)
84
+ show("f at the minimum", 0.5, optimum, quiet)
85
+ show("f at the endpoint (1, 0)", 1.0, endpoint, quiet)
86
+ show("largest |grad f + lambda grad g|", 0.0, parallel, quiet)
87
+ return {"solution": solution, "optimum": optimum}
88
+
89
+
90
+ # %% 04. Normalization as a constraint
91
+ def normalization(quiet: bool = False) -> dict:
92
+ """Section 1.37.2: entropy under normalization alone is maximized by the uniform distribution,
93
+ and the empirical negative log-likelihood is minimized by the observed frequencies."""
94
+ states = 4 # K, the size of the DNA alphabet.
95
+ uniform = torch.full((states,), 1.0 / states) # p_i = e^{lambda - 1}, the same at every state.
96
+ frequencies = BASE_COUNTS / BASE_COUNTS.sum() # p(x) = n(x) / N, the solution at lambda = 1.
97
+ loss = float(-(frequencies * frequencies.log()).sum()) # The minimum empirical loss, its entropy.
98
+ nearby = frequencies + torch.tensor([0.05, -0.05, 0.0, 0.0]) # A different categorical model.
99
+ worse = float(-(frequencies * nearby.log()).sum()) # Its loss on the same ten bases.
100
+ show("uniform p_i at K = 4", 0.25, float(uniform[0]), quiet)
101
+ show("max entropy under normalization", 1.386, entropy(uniform), quiet)
102
+ show("p_hat(A) = n(A) / N", 0.4, float(frequencies[0]), quiet)
103
+ show("p_hat(T) = n(T) / N", 0.1, float(frequencies[3]), quiet)
104
+ show("minimum empirical loss", 1.280, loss, quiet)
105
+ show("loss drop from moving off p_hat", 0.0, max(0.0, loss - worse), quiet)
106
+ if not quiet: # The displaced model is not a chapter number, so it is printed on its own.
107
+ print(f" {'loss of a model moved by 0.05':<36s} loss {worse:>11.5f}"
108
+ f" at p_hat {loss:>11.5f}")
109
+ return {"uniform": uniform, "frequencies": frequencies, "loss": loss, "worse": worse}
110
+
111
+
112
+ # %% 05. Maximum entropy under a property constraint
113
+ def maximum_entropy(samples: int, quiet: bool = False) -> dict:
114
+ """Section 1.37.3: with one multiplier per property the entropy maximizer is p* proportional to
115
+ exp(lambda h(x)), and the constraint fixes lambda."""
116
+ strong, weak = float(GC_INDICATOR.sum()), float((1.0 - GC_INDICATOR).sum()) # Two bases each.
117
+ exp_lambda = (GC_TARGET / (1.0 - GC_TARGET)) * (weak / strong) # From 2e^L/(2e^L + 2) = 0.6.
118
+ multiplier = math.log(exp_lambda) # lambda = log 1.5, the tilt toward the strong bases.
119
+ unnormalized = (multiplier * GC_INDICATOR).exp() # exp(lambda h(x)) at the four bases; [4].
120
+ partition = float(unnormalized.sum()) # Z(lambda) = 2 e^lambda + 2 = 5.
121
+ p_star = unnormalized / partition # The maximum-entropy distribution (0.2, 0.3, 0.3, 0.2); [4].
122
+ uniform = torch.full((4,), 0.25) # What lambda = 0 returns, with no property requirement.
123
+ weights = torch.rand(max(samples, 1), 2) # One split per base class, drawn independently; [N, 2].
124
+ competitors = torch.stack([ # Distributions that also meet the GC target, in A, C, G, T order.
125
+ (1.0 - GC_TARGET) * weights[:, 0], GC_TARGET * weights[:, 1],
126
+ GC_TARGET * (1.0 - weights[:, 1]), (1.0 - GC_TARGET) * (1.0 - weights[:, 0])], dim=1)
127
+ drawn = -(competitors * competitors.clamp_min(1e-300).log()).sum(1) # H of every competitor; [N].
128
+ best = float(drawn.max()) # The largest entropy reached by any distribution meeting the target.
129
+ show("e^lambda", 1.5, exp_lambda, quiet)
130
+ show("lambda = log 1.5", 0.4055, multiplier, quiet)
131
+ show("partition function Z", 5.0, partition, quiet)
132
+ show("p*(A)", 0.2, float(p_star[0]), quiet)
133
+ show("p*(C)", 0.3, float(p_star[1]), quiet)
134
+ show("GC content of p*", 0.6, float(p_star @ GC_INDICATOR), quiet)
135
+ show("H(p*)", 1.366, entropy(p_star), quiet)
136
+ show("H of the unconstrained uniform", 1.386, entropy(uniform), quiet)
137
+ show("entropy cost of the constraint", 0.0201, entropy(uniform) - entropy(p_star), quiet)
138
+ show(f"H above H(p*) over {len(drawn)} draws", 0.0, max(0.0, best - entropy(p_star)), quiet)
139
+ return {"multiplier": multiplier, "p_star": p_star, "partition": partition, "best_drawn": best}
140
+
141
+
142
+ # %% 06. Inequality constraints and the KKT conditions
143
+ def kkt(quiet: bool = False) -> dict:
144
+ """Section 1.38: on min (x - 3)^2 with x <= 1 the bound is active, so lambda is positive."""
145
+ x_star = 1.0 # The unconstrained minimizer 3 breaks the bound, so the solution is at it.
146
+ multiplier = -2.0 * (x_star - 3.0) # Stationarity 2(x - 3) + lambda = 0 solved at x = 1.
147
+ slack = x_star - 1.0 # g(x*) = x* - 1, which must be at or below zero.
148
+ show("x* at the bound", 1.0, x_star, quiet)
149
+ show("lambda, the inequality multiplier", 4.0, multiplier, quiet)
150
+ show("g(x*) = x* - 1", 0.0, slack, quiet)
151
+ show("complementary slackness lambda g", 0.0, multiplier * slack, quiet)
152
+ show("stationarity 2(x* - 3) + lambda", 0.0, 2.0 * (x_star - 3.0) + multiplier, quiet)
153
+ show("f at the unconstrained minimum", 0.0, (3.0 - 3.0) ** 2, quiet)
154
+ show("f under the bound", 4.0, (x_star - 3.0) ** 2, quiet)
155
+ show("value drop for epsilon = 0.001", 0.004, -((1.001 - 3.0) ** 2 - 4.0), quiet)
156
+ return {"multiplier": multiplier, "value": (x_star - 3.0) ** 2}
157
+
158
+
159
+ # %% 07. Optimization over the simplex through the softmax
160
+ def over_the_simplex(steps: int, quiet: bool = False) -> dict:
161
+ """Section 1.38.1: under logits the simplex constraints hold at every step, so the constrained
162
+ likelihood problem of Section 1.37.2 becomes an unconstrained descent that reaches p_hat."""
163
+ frequencies = BASE_COUNTS / BASE_COUNTS.sum() # The minimizer the multiplier route produced.
164
+ logits = torch.zeros(4, requires_grad=True) # z in R^4, starting at the uniform distribution.
165
+ learning_rate, losses = 1.0, [] # Plain gradient descent on z, with the loss after each step.
166
+ for _ in range(max(steps, 1)):
167
+ model = torch.softmax(logits, dim=0) # p_i = e^{z_i} / sum_j e^{z_j}, always on the simplex.
168
+ loss = -(frequencies * model.log()).sum() # The empirical negative log-likelihood.
169
+ gradient, = torch.autograd.grad(loss, logits) # d L / d z, which is softmax(z) - p_hat.
170
+ with torch.no_grad(): # The update itself needs no graph.
171
+ logits -= learning_rate * gradient # One descent step of Section 1.36, now on logits.
172
+ losses.append(float(loss.detach()))
173
+ final = torch.softmax(logits.detach(), dim=0) # The distribution the descent arrived at; [4].
174
+ show("sum_i p_i at the end", 1.0, float(final.sum()), quiet)
175
+ show("entries at or below zero", 0.0, float((final <= 0.0).sum()), quiet)
176
+ show("p(A) after the descent", 0.4, float(final[0]), quiet)
177
+ show("p(T) after the descent", 0.1, float(final[3]), quiet)
178
+ show("loss after the descent", 1.280, losses[-1], quiet)
179
+ show("largest |p - p_hat|", 0.0, float((final - frequencies).abs().max()), quiet)
180
+ return {"final": final, "losses": losses, "distance": float((final - frequencies).abs().max())}
181
+
182
+
183
+ # %% 08. The report
184
+ def main() -> None:
185
+ parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
186
+ parser.add_argument("--steps", type=int, default=600, help="gradient descent steps")
187
+ parser.add_argument("--samples", type=int, default=4000, help="competing distributions drawn")
188
+ parser.add_argument("--seed", type=int, default=0, help="random seed for the drawn competitors")
189
+ parser.add_argument("--out", type=str, default="", help="directory for the run's output")
190
+ parser.add_argument("--quiet", action="store_true", help="print only the final report")
191
+ args = parser.parse_args()
192
+
193
+ set_seed(args.seed) # Seed first, so the same seed gives the same competitors.
194
+ if not args.quiet:
195
+ print("Chapter 1, Sections 1.36 to 1.38, worked example by worked example\n")
196
+ descent = unconstrained(args.steps, args.quiet)
197
+ constrained = equality_constraint(args.quiet)
198
+ normalized = normalization(args.quiet)
199
+ tilted = maximum_entropy(args.samples, args.quiet)
200
+ bounded = kkt(args.quiet)
201
+ fitted = over_the_simplex(args.steps, args.quiet)
202
+
203
+ report = {
204
+ "descent steps run": len(descent["history"]),
205
+ "theta after the descent": descent["history"][-1],
206
+ "x_1 under x_1 + x_2 = 1": float(constrained["solution"][0]),
207
+ "lambda, equality constraint": float(constrained["solution"][2]),
208
+ "minimum empirical loss, nats": normalized["loss"],
209
+ "lambda for GC content 0.6": tilted["multiplier"],
210
+ "p*(C) under that constraint": float(tilted["p_star"][1]),
211
+ "H(p*), nats": entropy(tilted["p_star"]),
212
+ "best drawn H at the same target": tilted["best_drawn"],
213
+ "lambda, active inequality": bounded["multiplier"],
214
+ "f under the bound": bounded["value"],
215
+ "loss after the softmax descent": fitted["losses"][-1],
216
+ "largest |p - p_hat|": fitted["distance"],
217
+ }
218
+ print_report("Optimization, Sections 1.36 to 1.38", report)
219
+ save_run(args.out, config=vars(args), report=report)
220
+
221
+
222
+ if __name__ == "__main__": # Execute when launched directly.
223
+ main() # Work every example of Sections 1.36 to 1.38 and print the report.
lecture_1/probability.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Probability on discrete and continuous states, in the chapter's own numbers.
2
+
3
+ A distribution allocates one unit of mass over the states, expectation averages a property under
4
+ that allocation, variance and covariance record how the values fluctuate, and conditioning and
5
+ Bayes' rule revise the allocation once something has been observed. The chapter works every
6
+ example below by hand, so each section prints the hand value beside the value this file computes.
7
+
8
+ Chapter 1, Sections 1.3 to 1.11 of the notes. Runs on the two-token DNA distribution of
9
+ Section 1.3.1, the three peptides of Section 1.7.1 and the motif-and-binding table of
10
+ Section 1.9.1. Prints the chapter's number, the computed number and the gap between them.
11
+
12
+ python lecture_1/probability.py # every worked example
13
+ python lecture_1/probability.py --samples 100000 # a longer Monte Carlo ladder
14
+ python lecture_1/probability.py --quiet # the closing report only
15
+ """
16
+
17
+ # %% 01. Imports and configuration
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import math
22
+
23
+ import torch
24
+
25
+ from cis6270.runner import print_report, save_run, set_seed
26
+
27
+ torch.set_default_dtype(torch.float64) # Chapter 1 is arithmetic, and five printed digits need it.
28
+
29
+
30
+ STATES = ["AA", "AC", "CA", "CC"] # The state space X of Section 1.3.1, with L = 2 over {A, C}.
31
+ P_X = torch.tensor([0.4, 0.1, 0.2, 0.3]) # p_X over those four states, in the order of STATES.
32
+ A_COUNT = torch.tensor([2.0, 1.0, 1.0, 0.0]) # h(x), the number of A tokens in each state.
33
+ PEPTIDES = torch.tensor([[1.0, 2.0], [2.0, 3.0], [3.0, 7.0]]) # (hydrophobicity, binding score).
34
+ JOINT = torch.tensor([[0.32, 0.08], # Row x = 0, motif absent: columns are y = 0 and y = 1.
35
+ [0.24, 0.36]]) # Row x = 1, motif present: Table 1.1 of Section 1.9.1.
36
+
37
+
38
+ WORKED: dict[str, float] = {} # Every computed value, by label, for tests/test_lecture_1.py.
39
+
40
+
41
+ def show(label: str, by_hand: float, computed: float, quiet: bool = False) -> None:
42
+ """Print the chapter's hand-worked value beside the value this file computes, and keep it."""
43
+ WORKED[label] = computed # The test suite asserts these against the chapter's own numbers.
44
+ if not quiet: # Under --quiet only the closing report is printed.
45
+ computed = computed + 0.0 # Adding zero prints a negative zero as a plain zero.
46
+ print(f" {label:<36s} chapter {by_hand:>11.5f} computed {computed:>11.5f}"
47
+ f" gap {abs(by_hand - computed):.1e}")
48
+
49
+
50
+ # %% 02. A distribution over length-two sequences
51
+ def two_token_distribution(quiet: bool = False) -> dict:
52
+ """Section 1.3.1: the four masses sum to one, and the event {x_1 = A} has probability 0.5."""
53
+ total = float(P_X.sum()) # Normalization, sum over x in X of p_X(x).
54
+ event = [index for index, state in enumerate(STATES) if state[0] == "A"] # A = {x : x_1 = A}.
55
+ first_is_a = float(P_X[event].sum()) # P(X in A), the masses of {AA, AC} added together.
56
+ show("sum_x p_X(x)", 1.0, total, quiet)
57
+ show("P(x_1 = A) = p(AA) + p(AC)", 0.5, first_is_a, quiet)
58
+ return {"normalization": total, "p_first_is_a": first_is_a}
59
+
60
+
61
+ # %% 03. Densities for continuous states
62
+ def continuous_densities(quiet: bool = False) -> dict:
63
+ """Section 1.4: a density is probability per unit length, so its height may exceed one."""
64
+ uniform_height = 1.0 / 0.1 # The uniform density on [0, 0.1] has the value 1/0.1 there.
65
+ narrow_mass = uniform_height * 0.02 # P(0 <= X <= 0.02) is the height times the width.
66
+ gaussian_peak = 1.0 / (math.sqrt(2.0 * math.pi) * 0.1) # N(0, 0.1^2) at its center.
67
+ show("uniform[0, 0.1] density height", 10.0, uniform_height, quiet)
68
+ show("P(0 <= X <= 0.02)", 0.2, narrow_mass, quiet)
69
+ show("peak of N(0, 0.1^2)", 3.989, gaussian_peak, quiet)
70
+ return {"uniform_height": uniform_height, "gaussian_peak": gaussian_peak}
71
+
72
+
73
+ # %% 04. The expected adenine count
74
+ def expected_adenine(quiet: bool = False) -> dict:
75
+ """Section 1.5.1: E[h(X)] = sum_x p_X(x) h(x), the probability-weighted average of the count."""
76
+ expectation = float(P_X @ A_COUNT) # 0.4(2) + 0.1(1) + 0.2(1) + 0.3(0).
77
+ across_thousand = 1000.0 * expectation # The adenines expected in 1000 generated sequences.
78
+ show("E[h(X)], expected A count", 1.1, expectation, quiet)
79
+ show("expected A count in 1000 draws", 1100.0, across_thousand, quiet)
80
+ return {"expectation": expectation}
81
+
82
+
83
+ # %% 05. Monte Carlo estimation
84
+ def monte_carlo(samples: int, repeats: int, quiet: bool = False) -> dict:
85
+ """Section 1.5.3: the sample average estimates E[h(X)] with an error that shrinks like 1/sqrt(N).
86
+
87
+ The chapter's own ten-sample draw gave 1.2 against the exact 1.1, a gap well inside the
88
+ standard deviation 0.263 of a ten-sample average. We redraw here, so the estimate moves with
89
+ --seed while the exact value and the error scale stay put. Repeating the ten-sample estimate
90
+ ``repeats`` times turns that predicted 0.263 into a measured spread.
91
+ """
92
+ exact = float(P_X @ A_COUNT) # The target of the estimator, E[h(X)] = 1.1.
93
+ second_moment = float(P_X @ A_COUNT.square()) # E[h^2] = 0.4(4) + 0.1 + 0.2 = 1.9.
94
+ variance = second_moment - exact ** 2 # Var(h(X)) = 1.9 - 1.21 = 0.69.
95
+ show("E[h(X)^2]", 1.9, second_moment, quiet)
96
+ show("Var(h(X))", 0.69, variance, quiet)
97
+ ladder, size = [], 10 # The chapter quotes the error at N = 10 and at N = 1000.
98
+ while size <= max(samples, 10):
99
+ draws = torch.multinomial(P_X, size, replacement=True) # [size] state indices from p_X.
100
+ estimate = float(A_COUNT[draws].mean()) # (1/N) sum_n h(x^(n)), the Monte Carlo average.
101
+ ladder.append((size, estimate, math.sqrt(variance / size)))
102
+ size *= 10
103
+ if not quiet:
104
+ for size, estimate, error in ladder:
105
+ print(f" N = {size:<8d} estimate {estimate:.4f} exact {exact:.4f}"
106
+ f" sd of the average {error:.4f}")
107
+ show("sd of a 10-sample average", 0.263, math.sqrt(variance / 10), quiet)
108
+ show("sd of a 1000-sample average", 0.0263, math.sqrt(variance / 1000), quiet)
109
+ rounds = max(repeats, 2) # Two averages is the fewest a standard deviation can be taken over.
110
+ repeated = torch.multinomial(P_X, 10 * rounds, replacement=True) # [10 * rounds] state indices.
111
+ averages = A_COUNT[repeated].view(rounds, 10).mean(dim=1) # One ten-sample average per round.
112
+ measured = float(averages.std()) # The spread of those averages, which 0.263 predicts.
113
+ show(f"sd of {rounds} ten-sample averages", 0.263, measured, quiet)
114
+ return {"exact": exact, "variance": variance, "ladder": ladder, "measured": measured,
115
+ "rounds": rounds}
116
+
117
+
118
+ # %% 06. A worked variance
119
+ def worked_variance(quiet: bool = False) -> dict:
120
+ """Section 1.6.2: Var(X) = E[X^2] - (E[X])^2 on the values 0, 1, 2 with masses 0.2, 0.5, 0.3."""
121
+ values = torch.tensor([0.0, 1.0, 2.0]) # The three values X can take.
122
+ masses = torch.tensor([0.2, 0.5, 0.3]) # p_X at those three values.
123
+ first_moment = float(masses @ values) # E[X] = 0 + 0.5 + 0.6 = 1.1.
124
+ second_moment = float(masses @ values.square()) # E[X^2] = 0 + 0.5 + 1.2 = 1.7.
125
+ from_moments = second_moment - first_moment ** 2 # The boxed identity of Section 1.6.1.
126
+ from_definition = float(masses @ (values - first_moment).square()) # The two-pass route.
127
+ show("E[X]", 1.1, first_moment, quiet)
128
+ show("E[X^2]", 1.7, second_moment, quiet)
129
+ show("Var(X) from the two moments", 0.49, from_moments, quiet)
130
+ show("Var(X) from the definition", 0.49, from_definition, quiet)
131
+ show("sigma_X", 0.7, math.sqrt(from_moments), quiet)
132
+ return {"variance": from_moments, "sigma": math.sqrt(from_moments)}
133
+
134
+
135
+ # %% 07. Covariance, correlation and the covariance matrix
136
+ def peptide_covariance(quiet: bool = False) -> dict:
137
+ """Sections 1.7.1 and 1.7.2: three equally likely peptides, their correlation and their Sigma."""
138
+ mu = PEPTIDES.mean(dim=0) # (mu_X, mu_Y) = (2, 4), each coordinate mean; [2].
139
+ centered = PEPTIDES - mu # The deviations (-1, -2), (0, -1), (1, 3); [3, 2].
140
+ sigma_matrix = centered.T @ centered / PEPTIDES.shape[0] # E[(X-mu)(X-mu)^T]; [2, 2].
141
+ cov_xy = float(sigma_matrix[0, 1]) # Cov(X, Y) = (2 + 0 + 3)/3 = 5/3.
142
+ sd_x = math.sqrt(float(sigma_matrix[0, 0])) # sigma_X = sqrt(2/3).
143
+ sd_y = math.sqrt(float(sigma_matrix[1, 1])) # sigma_Y = sqrt(14/3).
144
+ rho = cov_xy / (sd_x * sd_y) # Correlation, which divides out the units of both variables.
145
+ show("mu_X", 2.0, float(mu[0]), quiet)
146
+ show("mu_Y", 4.0, float(mu[1]), quiet)
147
+ show("Cov(X, Y)", 5.0 / 3.0, cov_xy, quiet)
148
+ show("Var(X) = Sigma_11", 2.0 / 3.0, float(sigma_matrix[0, 0]), quiet)
149
+ show("Var(Y) = Sigma_22", 14.0 / 3.0, float(sigma_matrix[1, 1]), quiet)
150
+ show("sigma_X across the peptides", 0.8165, sd_x, quiet)
151
+ show("sigma_Y across the peptides", 2.160, sd_y, quiet)
152
+ show("rho_XY", 0.9449, rho, quiet)
153
+ show("Sigma symmetry, Sigma_21 - Sigma_12", 0.0, float(sigma_matrix[1, 0] - cov_xy), quiet)
154
+ return {"sigma_matrix": sigma_matrix, "rho": rho}
155
+
156
+
157
+ # %% 08. Gaussian noise and the noising equation
158
+ def noising_covariance(quiet: bool = False) -> dict:
159
+ """Section 1.8: Cov(X_t) = alpha_t^2 Cov(X_0) + sigma_t^2 I at alpha_t = 0.6, sigma_t = 0.8."""
160
+ alpha_t, sigma_t = 0.6, 0.8 # A variance-preserving pair, since 0.36 + 0.64 = 1.
161
+ identity = torch.eye(2) # The isotropic noise covariance; [2, 2].
162
+ isotropic = alpha_t ** 2 * identity + sigma_t ** 2 * identity # Data covariance I stays I.
163
+ wide_data = torch.diag(torch.tensor([4.0, 1.0])) # Cov(X_0) = diag(4, 1); [2, 2].
164
+ anisotropic = alpha_t ** 2 * wide_data + sigma_t ** 2 * identity # diag(2.08, 1.00).
165
+ show("alpha_t^2 + sigma_t^2", 1.0, alpha_t ** 2 + sigma_t ** 2, quiet)
166
+ show("Cov(X_t)_11 with Cov(X_0) = I", 1.0, float(isotropic[0, 0]), quiet)
167
+ show("Cov(X_t)_11 with diag(4, 1)", 2.08, float(anisotropic[0, 0]), quiet)
168
+ show("Cov(X_t)_22 with diag(4, 1)", 1.00, float(anisotropic[1, 1]), quiet)
169
+ return {"anisotropic": anisotropic}
170
+
171
+
172
+ # %% 09. Joint, marginal and conditional distributions
173
+ def joint_marginal_conditional(quiet: bool = False) -> dict:
174
+ """Sections 1.9.1 to 1.9.3: the motif-and-binding table, its marginals and its conditionals."""
175
+ total = float(JOINT.sum()) # 0.36 + 0.24 + 0.08 + 0.32 = 1.
176
+ p_x = JOINT.sum(dim=1) # p(x) = sum_y p(x, y), motif present or absent; [2].
177
+ p_y = JOINT.sum(dim=0) # p(y) = sum_x p(x, y), high or low binding; [2].
178
+ p_y_given_x = JOINT / p_x[:, None] # p(y | x) = p(x, y) / p(x), renormalized row by row; [2, 2].
179
+ show("sum over the four cells", 1.0, total, quiet)
180
+ show("p(X = 1), motif present", 0.60, float(p_x[1]), quiet)
181
+ show("p(X = 0), motif absent", 0.40, float(p_x[0]), quiet)
182
+ show("p(Y = 1), high binding", 0.44, float(p_y[1]), quiet)
183
+ show("p(Y = 0), low binding", 0.56, float(p_y[0]), quiet)
184
+ show("p(Y = 1 | X = 1)", 0.6, float(p_y_given_x[1, 1]), quiet)
185
+ show("p(Y = 0 | X = 1)", 0.4, float(p_y_given_x[1, 0]), quiet)
186
+ show("p(Y = 1 | X = 0)", 0.2, float(p_y_given_x[0, 1]), quiet)
187
+ show("p(Y = 0 | X = 0)", 0.8, float(p_y_given_x[0, 0]), quiet)
188
+ return {"p_x": p_x, "p_y": p_y, "p_y_given_x": p_y_given_x}
189
+
190
+
191
+ # %% 10. Bayes' rule and the law of total probability
192
+ def bayes(quiet: bool = False) -> dict:
193
+ """Section 1.10: the motif probability after observing high binding, with its own denominator."""
194
+ p_x = JOINT.sum(dim=1) # The prior p(x) over motif presence; [2].
195
+ p_y_given_x = JOINT / p_x[:, None] # The likelihood p(y | x); [2, 2].
196
+ evidence = float(p_y_given_x[:, 1] @ p_x) # p(y) = sum_x p(y | x) p(x), summed over the cause.
197
+ posterior = float(p_y_given_x[1, 1] * p_x[1]) / evidence # p(X = 1 | Y = 1) = 0.36 / 0.44.
198
+ show("evidence p(Y = 1)", 0.44, evidence, quiet)
199
+ show("posterior p(X = 1 | Y = 1)", 0.818, posterior, quiet)
200
+ show("prior p(X = 1)", 0.60, float(p_x[1]), quiet)
201
+ show("likelihood ratio 0.6 / 0.2", 3.0, float(p_y_given_x[1, 1] / p_y_given_x[0, 1]), quiet)
202
+ return {"evidence": evidence, "posterior": posterior}
203
+
204
+
205
+ # %% 11. Independence
206
+ def independence(quiet: bool = False) -> dict:
207
+ """Section 1.11: p(x, y) equals p(x) p(y) under independence, and the table fails that test."""
208
+ p_x = JOINT.sum(dim=1) # Marginal over motif presence; [2].
209
+ p_y = JOINT.sum(dim=0) # Marginal over binding activity; [2].
210
+ product = float(p_x[1] * p_y[1]) # 0.60 times 0.44 = 0.264 under a factorized model.
211
+ joint_entry = float(JOINT[1, 1]) # The table's own entry, 0.36.
212
+ show("p(X = 1) p(Y = 1)", 0.264, product, quiet)
213
+ show("p(X = 1, Y = 1)", 0.36, joint_entry, quiet)
214
+ show("association missed by factorizing", 0.096, joint_entry - product, quiet)
215
+ return {"product": product, "joint_entry": joint_entry, "gap": joint_entry - product}
216
+
217
+
218
+ # %% 12. The report
219
+ def main() -> None:
220
+ parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
221
+ parser.add_argument("--samples", type=int, default=1000, help="largest Monte Carlo sample size")
222
+ parser.add_argument("--steps", type=int, default=5000, help="repeats of the ten-sample estimate")
223
+ parser.add_argument("--seed", type=int, default=0, help="random seed for the Monte Carlo draws")
224
+ parser.add_argument("--out", type=str, default="", help="directory for the run's output")
225
+ parser.add_argument("--quiet", action="store_true", help="print only the final report")
226
+ args = parser.parse_args()
227
+
228
+ set_seed(args.seed) # Seed first, so the same seed gives the same Monte Carlo ladder.
229
+ if not args.quiet:
230
+ print("Chapter 1, Sections 1.3 to 1.11, worked example by worked example\n")
231
+ two_token_distribution(args.quiet)
232
+ continuous_densities(args.quiet)
233
+ adenine = expected_adenine(args.quiet)
234
+ estimates = monte_carlo(args.samples, args.steps, args.quiet)
235
+ spread = worked_variance(args.quiet)
236
+ peptides = peptide_covariance(args.quiet)
237
+ noising = noising_covariance(args.quiet)
238
+ table = joint_marginal_conditional(args.quiet)
239
+ posterior = bayes(args.quiet)
240
+ association = independence(args.quiet)
241
+
242
+ report = {
243
+ "E[h(X)], expected A count": adenine["expectation"],
244
+ "Monte Carlo estimate at N": estimates["ladder"][-1][0],
245
+ "Monte Carlo estimate": estimates["ladder"][-1][1],
246
+ "Var(h(X))": estimates["variance"],
247
+ "ten-sample averages taken": estimates["rounds"],
248
+ "sd of those averages": estimates["measured"],
249
+ "Var(X) on 0, 1, 2": spread["variance"],
250
+ "rho_XY across three peptides": peptides["rho"],
251
+ "Cov(X_t)_11 from diag(4, 1)": float(noising["anisotropic"][0, 0]),
252
+ "p(Y = 1), high binding": float(table["p_y"][1]),
253
+ "p(X = 1 | Y = 1)": posterior["posterior"],
254
+ "p(X=1,Y=1) - p(X=1)p(Y=1)": association["gap"],
255
+ }
256
+ print_report("Probability, Sections 1.3 to 1.11", report)
257
+ save_run(args.out, config=vars(args), report=report)
258
+
259
+
260
+ if __name__ == "__main__": # Execute when launched directly.
261
+ main() # Work every example of Sections 1.3 to 1.11 and print the report.
lecture_2/README.md ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Lecture 2: Flow Matching (Chapter 2)
2
+
3
+ A velocity field assigns one vector to every state and every time. Following it from a starting
4
+ point traces a trajectory, the trajectories from every starting point form the flow map, and the
5
+ continuity equation ties the motion of the density to the same field. Training such a field by
6
+ maximum likelihood costs an ODE solve and a divergence trace per gradient step, so flow matching
7
+ replaces it with a regression: pair a Gaussian sample with a data sample, join the pair by a
8
+ straight line, and regress a network onto the derivative of that line. Least squares recovers the
9
+ average velocity of all the paths through a state, which is the field that transports the prior
10
+ onto the data, and generation is Euler integration of the learned field.
11
+
12
+ `flow_matching_mnist.py` is the lecture's main script, the one Section 2.20 of the notes walks
13
+ through line by line. The other three files are the mathematics underneath it: the fields and the
14
+ solver before any training, the full method on a target whose density is known exactly, and the
15
+ family of interpolants the straight line belongs to.
16
+
17
+ | File | Command | What it does |
18
+ | --- | --- | --- |
19
+ | `flow_matching_mnist.py` | `python lecture_2/flow_matching_mnist.py` | Trains the small U-Net on MNIST and writes a 4-by-4 grid of digits and a trajectory strip. `--dataset toy` runs offline. |
20
+ | `velocity_fields.py` | `python lecture_2/velocity_fields.py` | No training. Trajectories, the flow map, the Euler refinement ladder, the pushforward of a Gaussian, the flux balance and the continuity equation. |
21
+ | `flow_matching_2d.py` | `python lecture_2/flow_matching_2d.py` | Trains a velocity MLP on the four-Gaussian target and samples by Euler. Measures the conditional flow-matching equivalence against the exact marginal velocity. |
22
+ | `interpolants.py` | `python lecture_2/interpolants.py` | Trains the same MLP three times, on the linear, the t-squared and the trigonometric schedule, and compares the samples at a fine and a coarse step budget. |
23
+
24
+ Nothing is trained in `velocity_fields.py`, so its `--steps` sets the largest rung of the Euler
25
+ refinement ladder. In the other three files `--steps` counts optimizer steps, and in
26
+ `flow_matching_mnist.py` it is the budget for the whole run, which `--epochs` would otherwise set.
27
+
28
+ ## The numbers a default run produces
29
+
30
+ `velocity_fields.py`, in about two seconds, reproduces the chapter's arithmetic exactly.
31
+
32
+ ```
33
+ euler steps 1 landing 2.000000 error 0.718282 error x steps 0.7183
34
+ euler steps 2 landing 2.250000 error 0.468282 error x steps 0.9366
35
+ euler steps 4 landing 2.441406 error 0.276876 error x steps 1.1075
36
+ flux_balance -0.3354 enclosed_mass_rate -0.3354
37
+ continuity_time_derivative 0.1123 continuity_minus_flux_divergence 0.1123
38
+ density_ratio_at_origin 2.0000 log_density_change -0.6931
39
+ ```
40
+
41
+ `flow_matching_2d.py`, in about six seconds on one core, with 83,202 parameters.
42
+
43
+ ```
44
+ crossing_conditional_loss [10.0, 9.0, 10.0] crossing_marginal_loss [1.0, 0.0, 1.0]
45
+ crossing_constant_gap [9.0, 9.0, 9.0] crossing_target_variance 9.0000
46
+ loss_cfm 10.3243 loss_fm 5.1196 loss_gap 5.2506 loss_cross_term -0.0459
47
+ gradient_cosine 0.9989 final_loss 5.3169
48
+ modes_found 4 mode_entropy 1.3657 nearest_mode_distance 0.4505
49
+ mean_log_density -2.2648 data_mean_log_density -2.1402
50
+ ```
51
+
52
+ `interpolants.py`, in about thirteen seconds, trains three fields from the same initialization.
53
+
54
+ ```
55
+ endpoint_error covariance_deviation mean_log_density coarse_log_density
56
+ linear 0.0000 0.4999 -2.5276 -3.0309
57
+ quadratic 0.0000 0.4939 -2.5748 -3.6419
58
+ trigonometric 0.0000 0.0179 -2.6621 -2.9566
59
+ ```
60
+
61
+ All three schedules reach the four modes. The trigonometric one is the only schedule whose
62
+ interpolation keeps identity covariance at every time, and it is the one that loses least when the
63
+ sampler drops from 64 Euler steps to 8, while the t-squared schedule, whose velocity grows linearly
64
+ in t, loses most.
65
+
66
+ `flow_matching_mnist.py` downloads MNIST on its first run. The offline path, `--dataset toy`,
67
+ trains the same 471,265-parameter U-Net on 512 bars and blobs in about two minutes on one core.
68
+
69
+ ```
70
+ epoch=20 squared_norm_loss=977.133
71
+ final loss 977.133 sample range [-5.15, 3.50]
72
+ ```
73
+
74
+ Twenty epochs over 512 images is 80 optimizer steps, and the generated range is still wider than
75
+ the data's [-1, 1]. With `--epochs 100` the loss reaches 387.136 and the range [-3.25, 3.04], in
76
+ about nine minutes. The MNIST default is 20 epochs over 60,000 images, and it is the one script
77
+ here that does not finish while you watch it. `--steps 20` trains for twenty optimizer steps and
78
+ then samples, which is the quick check that the file runs.
79
+
80
+ ## What is simplified
81
+
82
+ - The marginal velocity `v_t(x) = E[U_t | X_t = x]` is written in closed form in
83
+ `flow_matching_2d.py`. That is available because the target is a Gaussian mixture and the prior is
84
+ Gaussian; Section 2.14 of the notes is about the case where it is not, which is why the equivalence
85
+ it proves is worth proving.
86
+ - The equivalence is measured on one large batch, so the two losses agree up to that batch's own
87
+ sampling error. The file prints the cross term that accounts for the residual exactly.
88
+ - The identity-covariance check uses two independent standard Gaussian endpoints, which is the
89
+ hypothesis the chapter states. The four-Gaussian target is centered but not white.
90
+ - `velocity_fields.py` differentiates the densities with autograd where the chapter differentiates
91
+ by hand, so no derivative in that file is transcribed from the notes. It also runs in double
92
+ precision, because the Euler ladder it prints is arithmetic quoted to six decimal places.
93
+ - The Gaussian translation of Section 2.17 is left out. Its coupling is deterministic, so the
94
+ conditional velocity is the constant 4 at every state and time and already equals the marginal
95
+ velocity.
96
+ - The cubic Hermite interpolant of Section 2.18 is left out of `interpolants.py`. It needs endpoint
97
+ slopes, which the four-Gaussian data do not carry.
98
+ - The three two-dimensional scripts run on the CPU. Each state there has two coordinates, so a GPU
99
+ would be slower. `flow_matching_mnist.py` picks CUDA, then Apple MPS, then the CPU.
lecture_2/flow_matching_2d.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Flow matching on four Gaussians, the whole method end to end.
2
+
3
+ A coupling pairs a Gaussian prior sample with a data sample, a straight line joins the pair, and the
4
+ derivative of that line is the regression target. Least squares against those per-pair velocities
5
+ recovers the marginal velocity field, and Euler integration of the learned field turns fresh noise
6
+ into data. The four-Gaussian target is small enough that the marginal velocity has a closed form, so
7
+ the conditional flow-matching equivalence can be measured here and not only argued.
8
+
9
+ Chapter 2, Sections 2.11 to 2.16 and 2.19 of the notes. Trained on
10
+ ``cis6270.data.gaussian_mixture_2d``. Prints the worked endpoint pair, the average at the crossing,
11
+ the two losses with their constant gap, the agreement of their gradients, and the mode coverage and
12
+ mean exact log density of the generated points.
13
+
14
+ python lecture_2/flow_matching_2d.py # train and generate, about ten seconds
15
+ python lecture_2/flow_matching_2d.py --steps 20 # a first look, with no time to learn
16
+ """
17
+
18
+ # %% 01. Imports and configuration
19
+ import argparse
20
+
21
+ import torch
22
+
23
+ from cis6270.data import GAUSSIAN_CENTERS, GAUSSIAN_STD, gaussian_mixture_2d
24
+ from cis6270.metrics import mean_log_density, mode_report
25
+ from cis6270.nets import VelocityMLP, count_parameters
26
+ from cis6270.runner import print_report, save_run, set_seed, train
27
+
28
+ DIM = 2 # d, the width of the state.
29
+ TRAIN_STEPS = 1000 # Optimizer steps, each on one fresh batch of endpoint pairs.
30
+ BATCH_SIZE = 256 # Endpoint pairs per step.
31
+ LEARNING_RATE = 3e-3 # Optimizer step size.
32
+ WIDTH = 128 # Hidden width of the velocity network.
33
+ SAMPLE_STEPS = 64 # Euler steps from t = 0 to t = 1, so Delta t = 1/64.
34
+ NUM_SAMPLES = 1024 # Points generated for the mode coverage and the log density.
35
+ DATA_POINTS = 4096 # Size of the training set drawn from the four-Gaussian target.
36
+ EQUIVALENCE_POINTS = 16384 # Pairs used for the Monte Carlo check of Theorem 2.1.
37
+ GRID = torch.tensor([0.0, 0.25, 0.50, 0.75, 1.0]) # [5] the chapter's grid of times.
38
+
39
+
40
+ # %% 02. The coupling
41
+ #
42
+ # Section 2.11. A coupling is any joint law over the endpoint pair whose marginals are the prior and
43
+ # the data. Drawing the two ends with no reference to each other gives the independent coupling.
44
+ def sample_coupling(data, batch_size):
45
+ """Draw (X_0, X_1) ~ pi with pi(x_0, x_1) = p_0(x_0) p_1(x_1). Returns two [B, d] tensors."""
46
+ index = torch.randint(0, len(data), (batch_size,), device=data.device) # [B] which data point.
47
+ x_1 = data[index] # [B, d] the data endpoint, drawn from p_1.
48
+ x_0 = torch.randn_like(x_1) # [B, d] the prior endpoint, drawn from p_0 = N(0, I).
49
+ return x_0, x_1
50
+
51
+
52
+ # %% 03. The conditional path and its velocity
53
+ #
54
+ # Section 2.12. The straight segment at constant speed, and its derivative with the endpoints held
55
+ # fixed. The velocity is called conditional because only someone who knows both endpoints has it.
56
+ def conditional_path(x_0, x_1, t):
57
+ """X_t = (1 - t) X_0 + t X_1 and U_t = X_1 - X_0. Returns two [B, d] tensors."""
58
+ x_t = (1.0 - t)[:, None] * x_0 + t[:, None] * x_1 # [B, d] convex combination of the endpoints.
59
+ u_t = x_1 - x_0 # [B, d] the displacement, the same vector at every time.
60
+ return x_t, u_t
61
+
62
+
63
+ def worked_pair():
64
+ """The chapter's one-dimensional pair x_0 = -2, x_1 = 3, evaluated on the grid of times."""
65
+ x_0 = torch.full((len(GRID), 1), -2.0) # [5, 1] the same prior endpoint at every grid time.
66
+ x_1 = torch.full((len(GRID), 1), 3.0) # [5, 1] the same data endpoint at every grid time.
67
+ x_t, u_t = conditional_path(x_0, x_1, GRID) # The line x_t = -2 + 5t and its slope.
68
+ return {
69
+ "worked_pair_path": [round(float(v), 4) for v in x_t[:, 0]],
70
+ "worked_pair_velocity": float(u_t[0, 0]), # The slope 5, so 1.25 units in every quarter.
71
+ }
72
+
73
+
74
+ # %% 04. The marginal velocity
75
+ #
76
+ # Section 2.13. Several conditional paths pass through one state at one time, each with its own
77
+ # U_t, and a deterministic field assigns one vector there, the average v_t(x) = E[U_t | X_t = x].
78
+ # For this target the average is available exactly, and Section 2.14 is about the case where it
79
+ # is not. Conditioning on component k makes (X_1, X_t) jointly Gaussian, so the posterior over
80
+ # components is a softmax of the component log densities at X_t and v_t follows in closed form.
81
+ def marginal_velocity(x_t, t):
82
+ """v_t(x) = E[U_t | X_t = x] for a standard Gaussian prior and the four-Gaussian target."""
83
+ centers = GAUSSIAN_CENTERS.to(x_t) # [M, d] the four means of the mixture.
84
+ variance = (1.0 - t) ** 2 + (t * GAUSSIAN_STD) ** 2 # [B] variance of X_t given component k.
85
+ offset = x_t[:, None, :] - t[:, None, None] * centers # [B, M, d] distance to each shifted mean.
86
+ log_weight = -0.5 * (offset ** 2).sum(-1) / variance[:, None] # [B, M] up to a shared constant.
87
+ weight = torch.softmax(log_weight, dim=1) # [B, M] posterior over components given X_t.
88
+ mean_center = (weight[:, :, None] * centers).sum(1) # [B, d] posterior mean of the center.
89
+ # Writing E[X_1 | X_t] in terms of that posterior mean and cancelling the factor (1 - t) that
90
+ # U_t = (X_1 - X_t)/(1 - t) would otherwise divide by leaves one that stays finite at t = 1.
91
+ return ((1.0 - t)[:, None] * mean_center
92
+ - (1.0 - t - t * GAUSSIAN_STD ** 2)[:, None] * x_t) / variance[:, None] # [B, d].
93
+
94
+
95
+ def crossing_average():
96
+ """Two paths of Section 2.13 that meet at x = 2 at t = 0.5, with velocities 4 and -2."""
97
+ x_0 = torch.tensor([[0.0], [3.0]]) # [2, 1] the two prior endpoints.
98
+ x_1 = torch.tensor([[4.0], [1.0]]) # [2, 1] the two data endpoints.
99
+ x_t, u_t = conditional_path(x_0, x_1, torch.full((2,), 0.5)) # Both states land on x = 2.
100
+ return {
101
+ "crossing_state": float(x_t.mean()), # Both paths occupy this state at t = 0.5.
102
+ "crossing_velocities": [round(float(v), 4) for v in u_t[:, 0]],
103
+ "crossing_average_velocity": float(u_t.mean()), # The equally weighted average, 1.
104
+ }
105
+
106
+
107
+ # %% 05. The two objectives
108
+ #
109
+ # Sections 2.14 and 2.15. The flow-matching loss regresses on the marginal velocity, which is not
110
+ # available for real data, and the conditional loss regresses on the sampled U_t, which is.
111
+ def objectives(model, x_0, x_1, t):
112
+ """L_CFM, L_FM, the gap E||U_t - v_t||^2 and the cross term, on one batch of endpoint pairs."""
113
+ x_t, u_t = conditional_path(x_0, x_1, t) # [B, d] the state and its conditional velocity.
114
+ v_pred = model(x_t, t) # [B, d] the network sees the state and the time, never the endpoints.
115
+ v_t = marginal_velocity(x_t, t) # [B, d] the target of the loss we cannot evaluate in general.
116
+ loss_cfm = (v_pred - u_t).square().sum(1).mean() # E||v_theta(X_t, t) - U_t||^2.
117
+ loss_fm = (v_pred - v_t).square().sum(1).mean() # E||v_theta(X_t, t) - v_t(X_t)||^2.
118
+ gap = (u_t - v_t).square().sum(1).mean() # The conditional variance of the target.
119
+ # Expanding the square leaves L_CFM = L_FM + cross + gap on any batch. The proof of Theorem 2.1
120
+ # rests on the cross term having conditional mean zero, so on a large batch it is near zero.
121
+ cross = 2.0 * ((v_pred - v_t) * (v_t - u_t)).sum(1).mean()
122
+ return loss_cfm, loss_fm, gap, cross
123
+
124
+
125
+ # %% 06. The equivalence, measured
126
+ #
127
+ # Section 2.15. At the crossing the two losses are parabolas in the prediction a, nine units apart
128
+ # at every a, with one derivative between them. The same identity holds in expectation on the
129
+ # two-dimensional problem, up to the Monte Carlo error of the batch that estimates it.
130
+ def crossing_gap(predictions=(0.0, 1.0, 2.0)):
131
+ """The conditional and marginal losses at x = 2, t = 0.5, where the targets are 4 and -2."""
132
+ targets = torch.tensor([4.0, -2.0]) # [2] the two conditional velocities meeting there.
133
+ marginal = targets.mean() # The conditional mean of the target, which is 1.
134
+ conditional_values, marginal_values = [], []
135
+ for a in predictions: # One candidate prediction at a time.
136
+ conditional_values.append(round(float((a - targets).square().mean()), 4))
137
+ marginal_values.append(round(float((a - marginal) ** 2), 4))
138
+ return {
139
+ "crossing_conditional_loss": conditional_values,
140
+ "crossing_marginal_loss": marginal_values,
141
+ "crossing_constant_gap": [round(c - m, 4) for c, m in
142
+ zip(conditional_values, marginal_values)],
143
+ "crossing_target_variance": float(targets.var(unbiased=False)), # The same constant, 9.
144
+ }
145
+
146
+
147
+ def equivalence_check(model, data, num_points):
148
+ """Theorem 2.1 on the four-Gaussian problem: the gap in value and the agreement in gradient."""
149
+ x_0, x_1 = sample_coupling(data, num_points) # [N, d] pairs from the independent coupling.
150
+ t = torch.rand(num_points, device=x_1.device) # [N] one uniform time per pair.
151
+ loss_cfm, loss_fm, gap, cross = objectives(model, x_0, x_1, t)
152
+ parameters = list(model.parameters())
153
+ grad_cfm = torch.autograd.grad(loss_cfm, parameters, retain_graph=True) # Gradient of L_CFM.
154
+ grad_fm = torch.autograd.grad(loss_fm, parameters) # Gradient of L_FM, on the same batch.
155
+ flat_cfm = torch.cat([g.flatten() for g in grad_cfm]) # [P] one vector per objective.
156
+ flat_fm = torch.cat([g.flatten() for g in grad_fm]) # [P].
157
+ return {
158
+ "loss_cfm": float(loss_cfm.detach()),
159
+ "loss_fm": float(loss_fm.detach()),
160
+ "loss_gap": float(gap.detach()), # The constant that separates the two objectives.
161
+ "loss_cross_term": float(cross.detach()), # Zero in expectation, so small on this batch.
162
+ "loss_fm_plus_gap": float((loss_fm + gap).detach()), # L_CFM once the cross term is added.
163
+ "gradient_cosine": float(torch.nn.functional.cosine_similarity(flat_cfm, flat_fm, dim=0)),
164
+ # What separates the two gradients is the gradient of that cross term, which falls like one
165
+ # over the square root of the batch size.
166
+ "gradient_relative_difference": float((flat_cfm - flat_fm).norm() / flat_fm.norm()),
167
+ }
168
+
169
+
170
+ # %% 07. Training
171
+ #
172
+ # Section 2.19. One iteration draws a pair and a time, builds the state in closed form, subtracts
173
+ # the endpoints, and takes a gradient step. No ODE is solved while a training example is built.
174
+ def make_loss(model, data, batch_size):
175
+ """The closure runner.train calls, returning L_CFM on one fresh batch."""
176
+ def loss_fn(step):
177
+ x_0, x_1 = sample_coupling(data, batch_size) # [B, d] a fresh pair per step.
178
+ t = torch.rand(batch_size, device=x_1.device) # [B] t ~ Uniform(0, 1), one per pair.
179
+ x_t, u_t = conditional_path(x_0, x_1, t) # [B, d] the state and the regression target.
180
+ return (model(x_t, t) - u_t).square().sum(1).mean() # ||v_theta(X_t, t) - U_t||^2.
181
+ return loss_fn
182
+
183
+
184
+ # %% 08. Euler sampling
185
+ #
186
+ # Section 2.19. Generation starts from a fresh prior sample and integrates the learned field, so
187
+ # one sample costs as many forward passes as there are steps.
188
+ @torch.no_grad()
189
+ def sample(model, num_samples, steps):
190
+ """Integrate dX_t/dt = v_theta(X_t, t) from t = 0 to t = 1 on a uniform grid. Returns [N, d]."""
191
+ device = next(model.parameters()).device # Use the model's own device.
192
+ x_t = torch.randn(num_samples, DIM, device=device) # [N, d] the draw X_0 ~ p_0.
193
+ dt = 1.0 / steps # Delta t, the width of one interval.
194
+ for step in range(steps): # One network evaluation per step.
195
+ t = torch.full((num_samples,), step * dt, device=device) # [N] the shared grid time.
196
+ x_t = x_t + dt * model(x_t, t) # Euler: state += time increment times velocity.
197
+ return x_t
198
+
199
+
200
+ # %% 09. The report
201
+ def main():
202
+ parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
203
+ parser.add_argument("--steps", type=int, default=TRAIN_STEPS, help="optimizer steps")
204
+ parser.add_argument("--batch-size", type=int, default=BATCH_SIZE, help="pairs per step")
205
+ parser.add_argument("--lr", type=float, default=LEARNING_RATE, help="learning rate")
206
+ parser.add_argument("--width", type=int, default=WIDTH, help="hidden width of the network")
207
+ parser.add_argument("--samples", type=int, default=NUM_SAMPLES, help="points to generate")
208
+ parser.add_argument("--sample-steps", type=int, default=SAMPLE_STEPS, help="Euler steps")
209
+ parser.add_argument("--seed", type=int, default=0, help="random seed")
210
+ parser.add_argument("--out", type=str, default="", help="directory for the run's output")
211
+ parser.add_argument("--quiet", action="store_true", help="print only the final report")
212
+ args = parser.parse_args()
213
+
214
+ set_seed(args.seed) # Seed first, so the same seed gives the same report.
215
+ data = gaussian_mixture_2d(DATA_POINTS, seed=args.seed) # [N, d] the target p_1 = p_data.
216
+ model = VelocityMLP(dim=DIM, width=args.width) # v_theta(x, t), the learned velocity field.
217
+
218
+ report = {"parameters": count_parameters(model)}
219
+ report.update(worked_pair()) # Section 2.12.
220
+ report.update(crossing_average()) # Section 2.13.
221
+ report.update(crossing_gap()) # Section 2.15.
222
+ # The gradient comparison runs before training, where the two gradients are far from zero.
223
+ report.update(equivalence_check(model, data, EQUIVALENCE_POINTS)) # Section 2.15.
224
+
225
+ losses = train(model, make_loss(model, data, args.batch_size),
226
+ steps=args.steps, lr=args.lr, quiet=args.quiet)
227
+ generated = sample(model, args.samples, args.sample_steps) # [N, d] fresh points.
228
+ coverage = mode_report(generated) # Nearest mode, how many modes, how evenly they are visited.
229
+ report.update({
230
+ "final_loss": sum(losses[-100:]) / len(losses[-100:]), # Averaged over the last steps.
231
+ "modes_found": coverage["modes_found"],
232
+ "mode_entropy": coverage["mode_entropy"], # log 4 = 1.3863 when all four are equally used.
233
+ "nearest_mode_distance": coverage["nearest_distance"],
234
+ "mode_fractions": coverage["mode_fractions"],
235
+ "mean_log_density": mean_log_density(generated), # Exact log density of the target.
236
+ "data_mean_log_density": mean_log_density(data), # The same measurement on real points.
237
+ })
238
+
239
+ print_report("flow matching on four Gaussians", report)
240
+ save_run(args.out, model=model, config=vars(args), report=report, losses=losses)
241
+
242
+
243
+ # %% 10. Run the script
244
+ if __name__ == "__main__":
245
+ main()
lecture_2/flow_matching_mnist.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Flow matching on MNIST, from noise to a handwritten digit in one script.
2
+
3
+ We draw a Gaussian sample at time zero and a real image at time one, join them by a straight line,
4
+ and train a U-Net to predict the velocity of that line at a random time in between. Generation then
5
+ starts from fresh noise and takes Euler steps along the learned field. Chapter 2, Section 2.20 of
6
+ the notes walks through this file section by section.
7
+
8
+ python lecture_2/flow_matching_mnist.py # 20 epochs on MNIST, downloads on first use
9
+ python lecture_2/flow_matching_mnist.py --steps 20 # 20 optimizer steps, for a first look
10
+ python lecture_2/flow_matching_mnist.py --epochs 1 # a single pass, about two minutes on a CPU
11
+ python lecture_2/flow_matching_mnist.py --dataset toy # no download, for a first look or a test
12
+
13
+ The network class and the checkpoint keys match the published `flow_unet_mnist.pt`, so the saved
14
+ five-epoch run still loads.
15
+ """
16
+
17
+ # %% 01. Imports
18
+ import argparse
19
+ from pathlib import Path
20
+
21
+ import torch # Tensors, gradients, and optimization.
22
+ import torch.nn as nn # Layers and model classes.
23
+ import torch.nn.functional as F # Resize feature maps in the decoder.
24
+
25
+
26
+ # %% 02. Configuration
27
+ BATCH_SIZE = 128 # Images per training batch.
28
+ EPOCHS = 20 # Passes through the training set.
29
+ LEARNING_RATE = 2e-4 # Optimizer step size.
30
+ BASE_CHANNELS = 32 # U-Net widths: 32, 64, 128.
31
+ SAMPLE_STEPS = 100 # Euler steps from t=0 to t=1.
32
+ NUM_SAMPLES = 16 # Generate a 4-by-4 image grid.
33
+ SEED = 7 # Seed for initialization and training.
34
+ DATA_DIR = Path("data") # MNIST download location.
35
+ OUTPUT_DIR = Path("outputs/flow_matching_mnist") # Checkpoint and generated image folder.
36
+
37
+ DEVICE = torch.device(
38
+ "cuda" if torch.cuda.is_available() # NVIDIA GPU.
39
+ else "mps" if torch.backends.mps.is_available() # Apple GPU.
40
+ else "cpu" # CPU fallback.
41
+ )
42
+
43
+
44
+ # %% 03. Load real images
45
+ def make_loader(dataset="mnist", batch_size=BATCH_SIZE):
46
+ """Batches of images normalized to [-1, 1], the range the velocity field is trained on."""
47
+ from torch.utils.data import DataLoader, TensorDataset
48
+
49
+ if dataset == "toy": # Shapes like MNIST, drawn offline, so a test needs no download.
50
+ from cis6270.data import toy_images
51
+
52
+ images = toy_images(num_images=512, size=28, seed=SEED) # [512, 1, 28, 28].
53
+ labels = torch.zeros(len(images), dtype=torch.long) # Unused; generation is unconditional.
54
+ return DataLoader(TensorDataset(images, labels), batch_size=batch_size, shuffle=True)
55
+
56
+ from torchvision import datasets
57
+ from torchvision.transforms import v2
58
+
59
+ transform = v2.Compose([
60
+ v2.ToImage(), # Channel-first image: [1, 28, 28].
61
+ v2.ToDtype(torch.float32, scale=True), # Integer pixels 0-255 -> floats 0-1.
62
+ v2.Normalize(mean=(0.5,), std=(0.5,)), # Real data pixels: [0, 1] -> [-1, 1].
63
+ ])
64
+ mnist = datasets.MNIST(root=DATA_DIR, train=True, download=True, transform=transform)
65
+ return DataLoader(
66
+ mnist, batch_size=batch_size, shuffle=True, # Batch shape: [B, 1, 28, 28].
67
+ num_workers=0, pin_memory=(DEVICE.type == "cuda"), # Load in the main process.
68
+ )
69
+
70
+
71
+ # %% 04. Sample the conditional path
72
+ def sample_conditional_path(x1): # x1 contains normalized real images.
73
+ x0 = torch.randn_like(x1) # Independent Gaussian prior; same shape as x1.
74
+ t = torch.rand(x1.shape[0], device=x1.device) # One random time per image; [B].
75
+ t_image = t[:, None, None, None] # [B, 1, 1, 1]; share time across pixels.
76
+ xt = (1.0 - t_image) * x0 + t_image * x1 # Noise at t=0; real image at t=1.
77
+ ut = x1 - x0 # Derivative of the straight interpolation.
78
+ return xt, t, ut # Network input, time, and target velocity.
79
+
80
+
81
+ # %% 05. Define a simple convolution block
82
+ def conv_block(in_channels, out_channels):
83
+ return nn.Sequential(
84
+ nn.Conv2d(in_channels, out_channels, 3, padding=1), nn.SiLU(), # Keep H and W.
85
+ nn.Conv2d(out_channels, out_channels, 3, padding=1), nn.SiLU(), # Refine features.
86
+ )
87
+
88
+
89
+ # %% 06. Define the small U-Net
90
+ class FlowUNet(nn.Module):
91
+ def __init__(self, base_channels=BASE_CHANNELS):
92
+ super().__init__()
93
+ c = base_channels # Base width; c=32 by default.
94
+ self.encoder1 = conv_block(2, c) # Image + time -> c channels.
95
+ self.encoder2 = conv_block(c, 2 * c) # Encoder: c -> 2c.
96
+ self.middle = conv_block(2 * c, 4 * c) # Bottleneck: 2c -> 4c.
97
+ self.decoder2 = conv_block(6 * c, 2 * c) # Upsampled 4c + skip 2c -> 2c.
98
+ self.decoder1 = conv_block(3 * c, c) # Upsampled 2c + skip c -> c.
99
+ self.output = nn.Conv2d(c, 1, 1) # One signed velocity per pixel.
100
+ self.pool = nn.MaxPool2d(2) # Halve the image height and width.
101
+
102
+ # %% 07. Compute the velocity field
103
+ def forward(self, xt, t):
104
+ t_image = t[:, None, None, None].expand_as(xt) # Constant time channel.
105
+ x = torch.cat([xt, t_image], dim=1) # [B, 2, 28, 28].
106
+ skip1 = self.encoder1(x) # Keep full-resolution features.
107
+ skip2 = self.encoder2(self.pool(skip1)) # 28x28 -> 14x14.
108
+ x = self.middle(self.pool(skip2)) # 14x14 -> 7x7.
109
+ x = F.interpolate(x, size=skip2.shape[-2:], mode="nearest") # 7x7 -> 14x14.
110
+ x = self.decoder2(torch.cat([x, skip2], dim=1)) # Restore encoder detail.
111
+ x = F.interpolate(x, size=skip1.shape[-2:], mode="nearest") # 14x14 -> 28x28.
112
+ x = self.decoder1(torch.cat([x, skip1], dim=1)) # Restore fine detail.
113
+ v_pred = self.output(x) # Predict v_theta(xt, t).
114
+ return v_pred # [B, 1, 28, 28], matching xt.
115
+
116
+
117
+ # %% 08. Train for one epoch
118
+ def train_one_epoch(model, loader, optimizer, max_steps=0):
119
+ model.train() # Select training mode.
120
+ total_loss, num_images = 0.0, 0 # Accumulate an image-weighted epoch loss.
121
+ for step, (x1, _) in enumerate(loader): # Ignore digit labels: unconditional generation.
122
+ if max_steps and step >= max_steps:
123
+ break # The step budget the caller left for this epoch is spent.
124
+ x1 = x1.to(DEVICE) # Move real images to the model device.
125
+ xt, t, ut = sample_conditional_path(x1) # Fresh noise and times for this batch.
126
+ v_pred = model(xt, t) # The model receives only xt and t.
127
+ squared_error = (v_pred - ut).square() # Velocity error at every pixel.
128
+ loss = squared_error.flatten(1).sum(1).mean() # Sum pixels, average images.
129
+ optimizer.zero_grad(set_to_none=True) # Clear old gradients.
130
+ loss.backward() # Compute gradients with respect to weights.
131
+ optimizer.step() # Update the U-Net weights.
132
+ total_loss += loss.item() * x1.shape[0] # Weight by the actual batch size.
133
+ num_images += x1.shape[0] # Include the smaller final batch.
134
+ return total_loss / num_images # Average training loss per image.
135
+
136
+
137
+ # %% 09. Generate images with Euler integration
138
+ # Snapshots land every quarter of the default 100-step run, and the ODE states stay unclipped.
139
+ @torch.no_grad() # Sampling needs no gradient tracking.
140
+ def sample_images(model, num_samples=NUM_SAMPLES, steps=SAMPLE_STEPS):
141
+ if num_samples < 1 or steps < 1:
142
+ raise ValueError("num_samples and steps must be positive")
143
+ model.eval() # Select evaluation mode.
144
+ device = next(model.parameters()).device # Use the model device.
145
+ xt = torch.randn(num_samples, 1, 28, 28, device=device) # Initial x0.
146
+ dt = 1.0 / steps # Positive time increment.
147
+ snapshots = [xt.cpu().clone()] # Keep the initial noise for plotting.
148
+ for step in range(steps): # Integrate forward in time.
149
+ t = torch.full((num_samples,), step * dt, device=device) # Shared time grid.
150
+ v_pred = model(xt, t) # Reevaluate velocity at the current state.
151
+ xt = xt + dt * v_pred # Euler: state += time increment * velocity.
152
+ if (step + 1) % max(steps // 4, 1) == 0 or step + 1 == steps:
153
+ snapshots.append(xt.cpu().clone()) # Save intermediate and final states.
154
+ return xt, torch.stack(snapshots, dim=1) # Raw images; [B, snapshots, 1, 28, 28].
155
+
156
+
157
+ # %% 10. Set up the complete run
158
+ def main():
159
+ parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
160
+ parser.add_argument("--epochs", type=int, default=EPOCHS)
161
+ parser.add_argument(
162
+ "--steps", type=int, default=0,
163
+ help="optimizer steps in the whole run; 0 runs every requested epoch in full",
164
+ )
165
+ parser.add_argument("--batch-size", type=int, default=BATCH_SIZE)
166
+ parser.add_argument("--dataset", choices=["mnist", "toy"], default="mnist")
167
+ parser.add_argument("--sample-steps", type=int, default=SAMPLE_STEPS)
168
+ parser.add_argument("--samples", type=int, default=NUM_SAMPLES)
169
+ parser.add_argument("--seed", type=int, default=SEED)
170
+ parser.add_argument("--out", type=str, default=str(OUTPUT_DIR))
171
+ parser.add_argument("--quiet", action="store_true", help="print only the final report")
172
+ args = parser.parse_args()
173
+
174
+ torch.manual_seed(args.seed) # Seed training randomness.
175
+ out = Path(args.out)
176
+ out.mkdir(parents=True, exist_ok=True) # Create the output folder.
177
+ loader = make_loader(args.dataset, args.batch_size) # Download, normalize, and batch.
178
+ model = FlowUNet().to(DEVICE) # Create the velocity network.
179
+ optimizer = torch.optim.AdamW(
180
+ model.parameters(), lr=LEARNING_RATE, weight_decay=0.0, # Optimize the flow loss.
181
+ )
182
+ num_parameters = sum(p.numel() for p in model.parameters()) # Count weights and biases.
183
+ if not args.quiet:
184
+ print(f"device={DEVICE} dataset={args.dataset} parameters={num_parameters:,}")
185
+
186
+ # %% 11. Train and save the learned parameters
187
+ losses = [] # Record the training loss after each epoch.
188
+ budget = args.steps # Optimizer steps left in the run, counted across all epochs.
189
+ for epoch in range(1, args.epochs + 1): # Complete the requested passes through the data.
190
+ loss = train_one_epoch(model, loader, optimizer, budget) # Run the training loop.
191
+ losses.append(loss) # Keep the loss history.
192
+ if not args.quiet:
193
+ print(f"epoch={epoch:02d} squared_norm_loss={loss:.3f}")
194
+ if args.steps: # An epoch runs the smaller of the budget and the number of batches.
195
+ budget -= min(budget, len(loader)) # Optimizer steps this epoch used.
196
+ if budget <= 0:
197
+ break # The run's budget is spent, so the remaining epochs do not run.
198
+ torch.save({
199
+ "model": model.state_dict(), "losses": losses, # Weights and training curve.
200
+ "base_channels": BASE_CHANNELS, # Width needed to reconstruct the model.
201
+ "seed": args.seed, # Record the training seed.
202
+ "epochs_completed": len(losses), # How far this run got.
203
+ }, out / "flow_unet_mnist.pt")
204
+
205
+ # %% 12. Sample and save images
206
+ torch.manual_seed(args.seed + 1) # Fix the starting noise for generation.
207
+ samples, trajectory = sample_images(model, args.samples, args.sample_steps)
208
+ try:
209
+ from torchvision.utils import save_image # Only needed to write the PNGs.
210
+
211
+ display_samples = ((samples.cpu() + 1.0) / 2.0).clamp(0.0, 1.0) # Clip for display only.
212
+ save_image(display_samples, out / "samples.png", nrow=4) # Final images.
213
+ display_path = ((trajectory[:4] + 1.0) / 2.0).clamp(0.0, 1.0) # First four sample paths.
214
+ save_image(
215
+ display_path.flatten(0, 1), out / "trajectory.png", # One row per sample.
216
+ nrow=trajectory.shape[1], # Time increases across columns.
217
+ )
218
+ if not args.quiet:
219
+ print(f"wrote {out}/samples.png and {out}/trajectory.png")
220
+ except ImportError: # Without torchvision the run still trains and samples.
221
+ if not args.quiet:
222
+ print("torchvision is not installed, so no PNG was written")
223
+
224
+ print(f"final loss {losses[-1]:.3f} sample range "
225
+ f"[{float(samples.min()):.2f}, {float(samples.max()):.2f}]")
226
+
227
+
228
+ # %% 13. Run the script
229
+ if __name__ == "__main__": # Execute when launched directly.
230
+ main() # Load data, train, sample, and save.
lecture_2/interpolants.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Three interpolants between the prior and the data.
2
+
3
+ Replacing the weights 1 - t and t by any pair of schedules alpha_t and beta_t with alpha_0 = 1,
4
+ beta_0 = 0, alpha_1 = 0 and beta_1 = 1 leaves the conditional flow-matching argument intact, and
5
+ the regression target becomes the derivative alpha_dot X_0 + beta_dot X_1. The straight line, the
6
+ t-squared schedule and the trigonometric schedule differ in where the path goes, how fast it gets
7
+ there, and what the intermediate densities look like, so the same network trained on each produces
8
+ different samples.
9
+
10
+ Chapter 2, Section 2.18 of the notes. Trained on ``cis6270.data.gaussian_mixture_2d``. Prints the
11
+ chapter's table of positions and velocities at t = 0.25, the endpoint check for each schedule, the
12
+ identity covariance of the trigonometric schedule, and the samples each trained field produces at a
13
+ fine and at a coarse step budget.
14
+
15
+ python lecture_2/interpolants.py # train three fields, about fifteen seconds
16
+ python lecture_2/interpolants.py --steps 20 # a first look, with no time to learn
17
+ """
18
+
19
+ # %% 01. Imports and configuration
20
+ import argparse
21
+ import math
22
+
23
+ import torch
24
+
25
+ from cis6270.data import gaussian_mixture_2d
26
+ from cis6270.metrics import mean_log_density, mode_report
27
+ from cis6270.nets import VelocityMLP
28
+ from cis6270.runner import print_report, save_run, set_seed, train
29
+
30
+ DIM = 2 # d, the width of the state.
31
+ HALF_PI = math.pi / 2.0 # The constant in the trigonometric schedule and in its derivative.
32
+ TRAIN_STEPS = 1000 # Optimizer steps for each of the three fields.
33
+ BATCH_SIZE = 256 # Endpoint pairs per step.
34
+ LEARNING_RATE = 3e-3 # Optimizer step size.
35
+ WIDTH = 128 # Hidden width of the velocity network.
36
+ SAMPLE_STEPS = 64 # Euler steps in the fine sampling budget.
37
+ COARSE_STEPS = 8 # Euler steps in the coarse budget, where a varying velocity costs accuracy.
38
+ NUM_SAMPLES = 1024 # Points generated per schedule and per budget.
39
+ DATA_POINTS = 4096 # Size of the training set drawn from the four-Gaussian target.
40
+ COVARIANCE_POINTS = 8192 # Endpoint pairs used for the empirical covariance of X_t.
41
+ GRID = torch.tensor([0.0, 0.25, 0.50, 0.75, 1.0]) # [5] the chapter's grid of times.
42
+
43
+
44
+ # %% 02. The three schedules
45
+ #
46
+ # Section 2.18. Each returns alpha_t, beta_t and their time derivatives, evaluated on a batch of
47
+ # times. The pair of schedules fixes the path and the pair of derivatives fixes the target.
48
+ def linear(t):
49
+ """alpha_t = 1 - t and beta_t = t, the straight segment traversed at constant speed."""
50
+ return 1.0 - t, t, -torch.ones_like(t), torch.ones_like(t) # Four tensors of shape [B].
51
+
52
+
53
+ def quadratic(t):
54
+ """alpha_t = 1 - t^2 and beta_t = t^2, the same segment entered slowly and left quickly."""
55
+ return 1.0 - t ** 2, t ** 2, -2.0 * t, 2.0 * t # U_t = 2t (X_1 - X_0), zero speed at t = 0.
56
+
57
+
58
+ def trigonometric(t):
59
+ """alpha_t = cos(pi t / 2) and beta_t = sin(pi t / 2), a quarter circle in the coefficients."""
60
+ angle = HALF_PI * t # [B] the angle the coefficient pair has turned through.
61
+ return (torch.cos(angle), torch.sin(angle),
62
+ -HALF_PI * torch.sin(angle), HALF_PI * torch.cos(angle))
63
+
64
+
65
+ SCHEDULES = {"linear": linear, "quadratic": quadratic, "trigonometric": trigonometric}
66
+
67
+
68
+ def interpolate(schedule, x_0, x_1, t):
69
+ """X_t = alpha_t X_0 + beta_t X_1 and U_t = alpha_dot X_0 + beta_dot X_1. Two [B, d] tensors."""
70
+ alpha, beta, alpha_dot, beta_dot = schedule(t) # [B] each, the two weights and their rates.
71
+ x_t = alpha[:, None] * x_0 + beta[:, None] * x_1 # [B, d] the state on the conditional path.
72
+ u_t = alpha_dot[:, None] * x_0 + beta_dot[:, None] * x_1 # [B, d] the regression target.
73
+ return x_t, u_t
74
+
75
+
76
+ # %% 03. The endpoints and the velocity at one time
77
+ #
78
+ # Section 2.18. The two ends are correct when alpha_0 = 1, beta_0 = 0, alpha_1 = 0 and beta_1 = 1,
79
+ # and Table 2.1 reads the three paths at X_0 = (2, 0), X_1 = (0, 2) and t = 0.25.
80
+ def endpoint_and_table(name):
81
+ """The endpoint error of one schedule and its position and velocity at the chapter's input."""
82
+ x_0 = torch.tensor([[2.0, 0.0]]) # [1, d] the prior endpoint of the table.
83
+ x_1 = torch.tensor([[0.0, 2.0]]) # [1, d] the data endpoint of the table.
84
+ schedule = SCHEDULES[name]
85
+ ends, _ = interpolate(schedule, x_0.expand(2, DIM), x_1.expand(2, DIM),
86
+ torch.tensor([0.0, 1.0])) # [2, d] the states at t = 0 and t = 1.
87
+ start_error = (ends[0] - x_0[0]).abs().max() # X_0 is returned at t = 0.
88
+ finish_error = (ends[1] - x_1[0]).abs().max() # X_1 is returned at t = 1.
89
+ x_t, u_t = interpolate(schedule, x_0, x_1, torch.tensor([0.25])) # One quarter of the interval.
90
+ return {
91
+ f"{name}_endpoint_error": float(torch.maximum(start_error, finish_error)),
92
+ f"{name}_position_at_quarter": [round(float(v), 4) + 0.0 for v in x_t[0]],
93
+ f"{name}_velocity_at_quarter": [round(float(v), 4) + 0.0 for v in u_t[0]],
94
+ }
95
+
96
+
97
+ # %% 04. Identity covariance
98
+ #
99
+ # Section 2.18. For independent, centered endpoints with identity covariance the interpolation has
100
+ # covariance (alpha_t^2 + beta_t^2) I. That factor is cos^2 + sin^2 = 1 at every time for the
101
+ # trigonometric schedule, and below one between the ends for the other two.
102
+ def covariance_check(name, num_points, seed):
103
+ """The coefficient squares on the grid, and the largest deviation of Cov(X_t) from the identity."""
104
+ set_seed(seed) # Same draw for every schedule, so the comparison is between schedules alone.
105
+ x_0 = torch.randn(num_points, DIM) # [N, d] a centered, white prior endpoint.
106
+ x_1 = torch.randn(num_points, DIM) # [N, d] a centered, white data endpoint, independent of it.
107
+ identity = torch.eye(DIM) # [d, d] the covariance the chapter's hypothesis gives each endpoint.
108
+ squares, deviations = [], []
109
+ for t in GRID: # One time at a time, since the covariance is measured over the batch.
110
+ times = t.expand(num_points) # [N] the same time for every pair.
111
+ alpha, beta, _, _ = SCHEDULES[name](times)
112
+ squares.append(round(float(alpha[0] ** 2 + beta[0] ** 2), 4)) # The exact variance factor.
113
+ x_t, _ = interpolate(SCHEDULES[name], x_0, x_1, times) # [N, d] the interpolated states.
114
+ covariance = (x_t.T @ x_t) / num_points # [d, d] the empirical second moment, centered at 0.
115
+ deviations.append(float((covariance - identity).abs().max()))
116
+ return {
117
+ f"{name}_coefficient_squares": squares,
118
+ f"{name}_covariance_deviation": max(deviations),
119
+ }
120
+
121
+
122
+ # %% 05. Training one field per interpolant
123
+ #
124
+ # Section 2.18 and Section 2.19. The loop is the one of the straight-line case with the target
125
+ # replaced, so any difference in the samples comes from the schedule and not from the setup.
126
+ def train_one(name, data, args):
127
+ """Train v_theta against the conditional velocity of one schedule and return the loss history."""
128
+ set_seed(args.seed) # The same initialization and the same batches for every schedule.
129
+ model = VelocityMLP(dim=DIM, width=args.width) # v_theta(x, t).
130
+ schedule = SCHEDULES[name]
131
+
132
+ def loss_fn(step):
133
+ index = torch.randint(0, len(data), (args.batch_size,)) # [B] which data point to pair.
134
+ x_1 = data[index] # [B, d] the data endpoint.
135
+ x_0 = torch.randn_like(x_1) # [B, d] the prior endpoint, independent of it.
136
+ t = torch.rand(args.batch_size) # [B] t ~ Uniform(0, 1).
137
+ x_t, u_t = interpolate(schedule, x_0, x_1, t) # [B, d] the state and the target.
138
+ return (model(x_t, t) - u_t).square().sum(1).mean() # ||v_theta(X_t, t) - U_t||^2.
139
+
140
+ losses = train(model, loss_fn, steps=args.steps, lr=args.lr, quiet=args.quiet)
141
+ return model, losses
142
+
143
+
144
+ # %% 06. Euler sampling
145
+ #
146
+ # Section 2.19. Every schedule is sampled by the same integrator, so the step budget is the only
147
+ # thing a reader has to hold fixed when comparing them.
148
+ @torch.no_grad()
149
+ def sample(model, num_samples, steps):
150
+ """Integrate dX_t/dt = v_theta(X_t, t) from t = 0 to t = 1 on a uniform grid. Returns [N, d]."""
151
+ x_t = torch.randn(num_samples, DIM) # [N, d] the draw X_0 ~ p_0.
152
+ dt = 1.0 / steps # Delta t, the width of one interval.
153
+ for step in range(steps): # One network evaluation per step.
154
+ x_t = x_t + dt * model(x_t, torch.full((num_samples,), step * dt)) # One Euler step.
155
+ return x_t
156
+
157
+
158
+ def measure(model, args):
159
+ """Mode coverage and mean exact log density at the fine and the coarse step budget."""
160
+ set_seed(args.seed + 1) # The same starting noise for every schedule.
161
+ fine = sample(model, args.samples, args.sample_steps) # [N, d] points from the fine grid.
162
+ coarse = sample(model, args.samples, args.coarse_steps) # [N, d] points from the coarse grid.
163
+ report = mode_report(fine)
164
+ return {
165
+ "modes_found": report["modes_found"],
166
+ "mode_entropy": report["mode_entropy"],
167
+ "nearest_distance": report["nearest_distance"],
168
+ "mean_log_density": mean_log_density(fine),
169
+ "coarse_log_density": mean_log_density(coarse),
170
+ }
171
+
172
+
173
+ # %% 07. The report
174
+ def main():
175
+ parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
176
+ parser.add_argument("--steps", type=int, default=TRAIN_STEPS, help="optimizer steps per field")
177
+ parser.add_argument("--batch-size", type=int, default=BATCH_SIZE, help="pairs per step")
178
+ parser.add_argument("--lr", type=float, default=LEARNING_RATE, help="learning rate")
179
+ parser.add_argument("--width", type=int, default=WIDTH, help="hidden width of the network")
180
+ parser.add_argument("--samples", type=int, default=NUM_SAMPLES, help="points to generate")
181
+ parser.add_argument("--sample-steps", type=int, default=SAMPLE_STEPS, help="fine Euler steps")
182
+ parser.add_argument("--coarse-steps", type=int, default=COARSE_STEPS, help="coarse Euler steps")
183
+ parser.add_argument("--seed", type=int, default=0, help="random seed")
184
+ parser.add_argument("--out", type=str, default="", help="directory for the run's output")
185
+ parser.add_argument("--quiet", action="store_true", help="print only the final report")
186
+ args = parser.parse_args()
187
+
188
+ data = gaussian_mixture_2d(DATA_POINTS, seed=args.seed) # [N, d] the target p_1 = p_data.
189
+ report = {}
190
+ for name in SCHEDULES: # Linear, quadratic, trigonometric, in the order the chapter gives them.
191
+ report.update(endpoint_and_table(name)) # Section 2.18, before any training.
192
+ report.update(covariance_check(name, COVARIANCE_POINTS, args.seed))
193
+ if not args.quiet:
194
+ print(f"training the {name} schedule")
195
+ model, losses = train_one(name, data, args)
196
+ report[f"{name}_final_loss"] = sum(losses[-100:]) / len(losses[-100:])
197
+ # The three losses are not comparable with each other, since each regresses on its own
198
+ # target, and the three measurements below are.
199
+ for key, value in measure(model, args).items():
200
+ report[f"{name}_{key}"] = value
201
+ save_run(f"{args.out}/{name}" if args.out else "", model=model, losses=losses)
202
+
203
+ report["data_mean_log_density"] = mean_log_density(data) # The same measurement on real points.
204
+ print_report("three interpolants on four Gaussians", report)
205
+ save_run(args.out, config=vars(args), report=report)
206
+
207
+
208
+ # %% 08. Run the script
209
+ if __name__ == "__main__":
210
+ main()
lecture_2/velocity_fields.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Velocity fields, flow maps and the continuity equation.
2
+
3
+ A velocity field assigns one vector to every state and every time, and following those vectors from
4
+ a starting point traces a trajectory. The trajectories from every starting point together form the
5
+ flow map, and the continuity equation relates the motion of the density to the same field. Nothing
6
+ here is learned, so every number printed has a closed form beside it.
7
+
8
+ Chapter 2, Sections 2.2 to 2.8 of the notes. Runs on the chapter's analytic fields in one and two
9
+ dimensions. Prints the trajectories, the Euler error at one, two and four steps and the gap closing
10
+ like 1/N, the pushforward of a Gaussian prior, the flux balance on [-1, 1], both sides of the
11
+ continuity equation at x = 2, and the log density along one trajectory.
12
+
13
+ python lecture_2/velocity_fields.py # the full report
14
+ python lecture_2/velocity_fields.py --steps 20 # a shorter Euler refinement ladder
15
+ """
16
+
17
+ # %% 01. Imports and configuration
18
+ import argparse
19
+ import math
20
+
21
+ import torch
22
+
23
+ from cis6270.runner import print_report, save_run, set_seed
24
+
25
+ torch.set_default_dtype(torch.float64) # Six printed digits of the Euler ladder need it.
26
+
27
+ LOG2 = math.log(2.0) # The rate a of the expansion field v_t(x) = a x, so that e^{at} = 2^t.
28
+ GRID = torch.tensor([0.0, 0.25, 0.50, 0.75, 1.0]) # [5] the chapter's grid of times.
29
+ START_POINTS = torch.tensor([-1.0, 0.0, 1.0]) # [3] the initial states followed through each field.
30
+ EULER_STEPS = 64 # Largest step count in the refinement ladder, halved down to one step.
31
+ NUM_SAMPLES = 4096 # Draws from the prior p_0 used for the pushforward moments.
32
+
33
+
34
+ # %% 02. Three velocity fields and the trajectories they produce
35
+ #
36
+ # Section 2.2 and Section 2.3. Each field is a rule at a single instant, written as a function of
37
+ # the state x and the time t, and each has an exact solution to compare a solver against.
38
+ def constant_field(x, t):
39
+ """v_t(x) = 2, the field that translates every state at the same speed."""
40
+ return torch.full_like(x, 2.0) # Same shape as x; the value depends on neither x nor t.
41
+
42
+
43
+ def linear_field(x, t, a=LOG2):
44
+ """v_t(x) = a x, which expands away from the origin for a > 0 and contracts toward it for a < 0."""
45
+ return a * x # Speed grows with distance from the origin and vanishes at x = 0.
46
+
47
+
48
+ def rotation_field(x, t):
49
+ """v(x_1, x_2) = (-x_2, x_1), everywhere perpendicular to the position vector."""
50
+ return torch.stack([-x[..., 1], x[..., 0]], dim=-1) # [..., 2] same shape as x.
51
+
52
+
53
+ def exact_linear(x_0, t, a=LOG2):
54
+ """x_t = x_0 e^{at}, the solution of dx_t/dt = a x_t obtained by separating variables."""
55
+ return x_0 * torch.exp(a * t) # Broadcasts a grid of times against a set of starting points.
56
+
57
+
58
+ def trajectories():
59
+ """The chapter's worked trajectories for translation, expansion, contraction and rotation."""
60
+ speed = constant_field(torch.tensor(1.0), torch.tensor(0.0)) # The value 2, at any state.
61
+ translated = 1.0 + speed * GRID # [5] the integral form x_t = x_0 + 2t from x_0 = 1.
62
+ expanded = exact_linear(START_POINTS, torch.tensor(1.0)) # [3] states at t = 1, each doubled.
63
+ contracted = exact_linear(START_POINTS, torch.tensor(1.0), a=-LOG2) # [3] states each halved.
64
+ turns = torch.tensor([math.pi / 2, math.pi]) # [2] the quarter turn and the half turn.
65
+ circle = torch.stack([torch.cos(turns), torch.sin(turns)], dim=-1) # [2, 2] x_t = (cos t, sin t).
66
+ # The squared norm is conserved because the field is orthogonal to the position: 2 x . v_t(x) = 0.
67
+ off_axis = torch.tensor([0.3, 0.7]) # [2] a state away from the axes, to test the conservation.
68
+ norm_rate = 2.0 * (off_axis * rotation_field(off_axis, torch.tensor(0.0))).sum() # d||x||^2/dt.
69
+ return {
70
+ "constant_field_trajectory": [round(float(v), 4) for v in translated],
71
+ "expansion_x_half_from_two": float(exact_linear(torch.tensor(2.0), torch.tensor(0.5))),
72
+ "expansion_endpoints": [round(float(v), 4) + 0.0 for v in expanded],
73
+ "expansion_outer_spacing": float(expanded[2] - expanded[0]),
74
+ "contraction_endpoints": [round(float(v), 4) + 0.0 for v in contracted],
75
+ "contraction_outer_spacing": float(contracted[2] - contracted[0]),
76
+ "rotation_quarter_turn": [round(float(v), 4) + 0.0 for v in circle[0]],
77
+ "rotation_half_turn": [round(float(v), 4) + 0.0 for v in circle[1]],
78
+ "rotation_radius": float(circle.norm(dim=-1).mean()),
79
+ "rotation_squared_norm_rate": float(norm_rate),
80
+ }
81
+
82
+
83
+ # %% 03. The flow map
84
+ #
85
+ # Section 2.4. Solving the ODE from every starting point at once defines phi_t, which is the identity
86
+ # at t = 0 and, for the expansion field, multiplies the whole line by 2^t.
87
+ def flow_map(x_0, t, a=LOG2):
88
+ """phi_t(x_0) = x_0 e^{at}, the accumulated transformation after following v_t for a time t."""
89
+ return exact_linear(x_0, t, a) # [N] the state each starting point has reached by time t.
90
+
91
+
92
+ def flow_map_report():
93
+ """The identity at t = 0, the Jacobian determinant at t = 1, and noncrossing trajectories."""
94
+ probe = torch.linspace(-3.0, 3.0, 101) # [101] states spread over the line.
95
+ identity_error = (flow_map(probe, torch.tensor(0.0)) - probe).abs().max() # phi_0 = id.
96
+ x_0 = torch.tensor(1.3, requires_grad=True) # A state at which to differentiate phi_1.
97
+ jacobian = torch.autograd.grad(flow_map(x_0, torch.tensor(1.0)), x_0)[0] # d phi_1 / d x_0 = 2.
98
+ # Proposition 2.1: distinct trajectories of a Lipschitz field never share a state at a shared time.
99
+ gaps = (flow_map(START_POINTS[2], GRID) - flow_map(START_POINTS[0], GRID)).abs() # [5].
100
+ return {
101
+ "flow_map_identity_error": float(identity_error),
102
+ "flow_map_jacobian_det": float(jacobian),
103
+ "trajectory_gap_minimum": float(gaps.min()),
104
+ }
105
+
106
+
107
+ # %% 04. Euler's method
108
+ #
109
+ # Section 2.5. Within one step the velocity stays fixed at its value at the start of the step, so
110
+ # on dx_t/dt = x_t, where the velocity grows with the state, every step undershoots.
111
+ def euler(field, x_0, steps):
112
+ """hat x_{k+1} = hat x_k + Delta t v_{t_k}(hat x_k) on a uniform grid of ``steps`` intervals."""
113
+ x_hat = x_0.clone() # The running approximation, started from the exact initial state.
114
+ dt = 1.0 / steps # Delta t, the width of one interval of the grid.
115
+ for k in range(steps): # One velocity evaluation per step.
116
+ x_hat = x_hat + dt * field(x_hat, torch.tensor(k * dt)) # The Euler update at grid time t_k.
117
+ return x_hat
118
+
119
+
120
+ def euler_ladder(largest):
121
+ """Euler on dx_t/dt = x_t from x_0 = 1, at every power of two up to ``largest`` steps."""
122
+ exact = math.e # The exact solution x_1 = e^1, the chapter's 2.7183.
123
+ counts, landings, errors = [], [], []
124
+ steps = 1
125
+ largest = max(largest, 4) # The chapter's three rungs, at one, two and four steps, always run.
126
+ while steps <= largest: # 1, 2, 4, 8, ... up to the requested largest step count.
127
+ landing = float(euler(lambda x, t: x, torch.tensor(1.0), steps)) # The field is v_t(x) = x.
128
+ counts.append(steps)
129
+ landings.append(round(landing, 6))
130
+ errors.append(round(exact - landing, 6))
131
+ steps *= 2
132
+ return {
133
+ "euler_step_counts": counts,
134
+ "euler_landings": landings,
135
+ "euler_errors": errors,
136
+ # A first-order method has error proportional to 1/N, so the error times N settles at e/2.
137
+ "euler_error_times_steps": [round(e * n, 4) for e, n in zip(errors, counts)],
138
+ "euler_error_halving_ratios": [round(errors[i] / errors[i + 1], 4)
139
+ for i in range(len(errors) - 1)],
140
+ "euler_relative_error_four_steps": float((exact - landings[2]) / exact),
141
+ }
142
+
143
+
144
+ # %% 05. A random starting point
145
+ #
146
+ # Section 2.6. The initial state becomes a random variable, each draw follows the same field, and
147
+ # the law at time t is the pushforward p_t = (phi_t)_# p_0.
148
+ def pushforward_moments(num_samples, seed):
149
+ """Moments of phi_1(X_0) for X_0 ~ N(0, 1) under the expansion field, against N(0, 4)."""
150
+ set_seed(seed) # Same seed, same draw, same report.
151
+ x_0 = torch.randn(num_samples) # [N] draws from the prior p_0 = N(0, 1).
152
+ x_1 = flow_map(x_0, torch.tensor(1.0)) # [N] states at t = 1, every one of them doubled.
153
+ return {
154
+ "pushforward_mean": float(x_1.mean()), # Exact value 0, since the flow fixes the origin.
155
+ "pushforward_variance": float(x_1.var(unbiased=False)), # Scaling by 2 gives variance 4.
156
+ "pushforward_exact_variance": 4.0, # Scaling a centered law by c multiplies variance by c^2.
157
+ }
158
+
159
+
160
+ # %% 06. Probability flux and the continuity equation
161
+ #
162
+ # Section 2.7. The flux j_t(x) = p_t(x) v_t(x) measures probability transported per unit time, and
163
+ # the balance over an interval becomes the continuity equation once the interval shrinks to a point.
164
+ def spreading_density(x, t):
165
+ """p_t(x) for X_t ~ N(0, 4^t), the law the expansion field produces from a standard normal."""
166
+ std = torch.pow(torch.tensor(2.0), t) # 2^t, the standard deviation at time t.
167
+ return torch.exp(-x ** 2 / (2.0 * std ** 2)) / (std * math.sqrt(2.0 * math.pi))
168
+
169
+
170
+ def flux_balance():
171
+ """The two endpoint fluxes on [-1, 1] at t = 0, and the rate of change of the enclosed mass."""
172
+ t_0 = torch.tensor(0.0)
173
+ endpoints = torch.tensor([-1.0, 1.0]) # [2] the interval's endpoints a and b.
174
+ density = spreading_density(endpoints, t_0) # [2] p_0(-1) and p_0(1), both 0.2420.
175
+ flux = density * linear_field(endpoints, t_0) # [2] j_0(x) = p_0(x) v_0(x).
176
+ # The same rate read off the distribution: M_t = erf(2^{-t} / sqrt 2), differentiated in t.
177
+ t = torch.tensor(0.0, requires_grad=True)
178
+ mass = torch.erf(torch.pow(torch.tensor(2.0), -t) / math.sqrt(2.0)) # P(|X_t| <= 1).
179
+ mass_rate = torch.autograd.grad(mass, t)[0] # d M_t / dt at t = 0.
180
+ return {
181
+ "density_at_one": float(density[1]),
182
+ "flux_at_minus_one": float(flux[0]),
183
+ "flux_at_one": float(flux[1]),
184
+ "flux_balance": float(flux[0] - flux[1]), # j_0(a) - j_0(b), the net rate into [-1, 1].
185
+ "enclosed_mass": float(mass.detach()),
186
+ "enclosed_mass_rate": float(mass_rate),
187
+ }
188
+
189
+
190
+ def continuity_check():
191
+ """Both sides of the continuity equation at x = 2 and t = 0, in the expanded product-rule form."""
192
+ x = torch.tensor(2.0, requires_grad=True) # The position held fixed while the density changes.
193
+ t = torch.tensor(0.0, requires_grad=True) # The time at which both sides are evaluated.
194
+ p = spreading_density(x, t) # p_0(2), the chapter's 0.05399.
195
+ dp_dt = torch.autograd.grad(p, t, create_graph=True)[0] # Local time derivative of the density.
196
+ dp_dx = torch.autograd.grad(p, x, create_graph=True)[0] # Spatial gradient of the density.
197
+ divergence = torch.autograd.grad(linear_field(x, t), x)[0] # div v = log 2 in one dimension.
198
+ transport = linear_field(x, t) * dp_dx # v_t(x) . grad p_t(x), the transport term.
199
+ volume = p * divergence # p_t(x) div v_t(x), the local volume-change term.
200
+ return {
201
+ "density_at_two": float(p.detach()),
202
+ "density_gradient_at_two": float(dp_dx.detach()),
203
+ "velocity_divergence": float(divergence),
204
+ "continuity_time_derivative": float(dp_dt.detach()),
205
+ "continuity_minus_flux_divergence": float(-(transport + volume).detach()),
206
+ }
207
+
208
+
209
+ # %% 07. The density along a moving trajectory
210
+ #
211
+ # Section 2.8. An observer riding with the sample sees no transport term, so the log density changes
212
+ # at the rate -div v_t(x_t), and over a unit of time that agrees with the Jacobian of the flow map.
213
+ def log_density_along_trajectory(x_start=0.7):
214
+ """The instantaneous change-of-variables equation on the expansion field, both ways."""
215
+ x_0 = torch.tensor(x_start) # One starting point, carried from t = 0 to t = 1.
216
+ x_1 = flow_map(x_0, torch.tensor(1.0)) # The state the trajectory reaches at t = 1.
217
+ log_p0 = torch.log(spreading_density(x_0, torch.tensor(0.0))) # log p_0(x_0).
218
+ log_p1 = torch.log(spreading_density(x_1, torch.tensor(1.0))) # log p_1(x_1).
219
+ origin = torch.tensor(0.0) # The fixed point of the flow, where phi_1(0) = 0.
220
+ prior_at_origin = float(spreading_density(origin, torch.tensor(0.0))) # p_0(0) = 0.3989.
221
+ final_at_origin = float(spreading_density(origin, torch.tensor(1.0))) # p_1(0) = 0.1995.
222
+ return {
223
+ "log_density_rate": -LOG2, # d log p_t(x_t) / dt = -div v_t(x_t), a constant here.
224
+ "log_density_change": float(log_p1 - log_p0), # Measured from the two densities.
225
+ "log_density_accumulated": -LOG2 * 1.0, # The integral of -div v_s over s from 0 to 1.
226
+ "prior_density_at_origin": prior_at_origin,
227
+ "final_density_at_origin": final_at_origin,
228
+ "density_ratio_at_origin": prior_at_origin / final_at_origin, # |det J_{phi_1}| = 2.
229
+ }
230
+
231
+
232
+ # %% 08. The report
233
+ def main():
234
+ parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
235
+ parser.add_argument("--steps", type=int, default=EULER_STEPS, help="largest Euler step count")
236
+ parser.add_argument("--samples", type=int, default=NUM_SAMPLES, help="draws from the prior")
237
+ parser.add_argument("--seed", type=int, default=0, help="random seed")
238
+ parser.add_argument("--out", type=str, default="", help="directory for the run's report")
239
+ parser.add_argument("--quiet", action="store_true", help="print only the final report")
240
+ args = parser.parse_args()
241
+
242
+ report = {}
243
+ report.update(trajectories()) # Sections 2.2 and 2.3.
244
+ report.update(flow_map_report()) # Section 2.4.
245
+ report.update(euler_ladder(args.steps)) # Section 2.5.
246
+ report.update(pushforward_moments(args.samples, args.seed)) # Section 2.6.
247
+ report.update(flux_balance()) # Section 2.7.
248
+ report.update(continuity_check()) # Section 2.7.
249
+ report.update(log_density_along_trajectory()) # Section 2.8.
250
+
251
+ if not args.quiet: # The Euler ladder, rung by rung, as the chapter develops it.
252
+ for n, landing, error in zip(report["euler_step_counts"], report["euler_landings"],
253
+ report["euler_errors"]):
254
+ print(f" euler steps {n:3d} landing {landing:.6f} error {error:.6f}"
255
+ f" error x steps {error * n:.4f}")
256
+
257
+ print_report("velocity fields, flow maps and the continuity equation", report)
258
+ save_run(args.out, report=report) # Writes report.json when --out names a directory.
259
+
260
+
261
+ # %% 09. Run the script
262
+ if __name__ == "__main__":
263
+ main()
lecture_3/README.md ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()
lecture_4/README.md ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Lecture 4: Discrete Diffusion
2
+
3
+ Chapter 4 keeps every state inside a finite vocabulary and replaces the continuous displacement of
4
+ Chapter 3 by a decision about which tokens to erase, reveal or replace. The chapter builds masked
5
+ diffusion by hand from a survival schedule, then rebuilds it as one member of a family: a rate
6
+ matrix says what a token can do in the next instant, the master equation follows the marginals, the
7
+ matrix exponential gives the corruption kernel at any time, and time reversal turns the forward
8
+ rates into the rates that generate. Seven published models come out of that family by changing the
9
+ rate matrix, the time variable, what the network predicts, or the state space itself, and a
10
+ further group keeps the rates and changes the order in which positions are filled, the weighting
11
+ of the loss, or the distribution to sample from. Every file here runs on the shared synthetic DNA
12
+ of `cis6270.data`, with `t = 0` clean and `t = 1` all masks, survival probability `alpha_t`, and
13
+ rates `R_t(x, y)` in the row convention `p_dot = p R`.
14
+
15
+ ## The files
16
+
17
+ | File | Command | What it trains and generates |
18
+ | --- | --- | --- |
19
+ | `ctmc.py` | `python lecture_4/ctmc.py` | Nothing is trained. Works every number of the CTMC sections: the two rules, one row for DNA, waiting times, the master equation, the matrix exponential, the `R_pi` family, eigenvalues, detailed balance and time reversal. |
20
+ | `mdlm.py` | `python lecture_4/mdlm.py` | Trains the denoiser on the weighted cross-entropy at masked positions, generates DNA from all masks. The chapter's thirty-line model. |
21
+ | `uniform.py` | `python lecture_4/uniform.py` | Trains against the rate loss under uniform replacement, generates by tau-leaping from uniformly random tokens. |
22
+ | `d3pm.py` | `python lecture_4/d3pm.py` | Trains against the discrete-time variational bound plus the auxiliary cross-entropy, generates by `T` ancestral steps. |
23
+ | `sedd.py` | `python lecture_4/sedd.py` | Trains a network to output the concrete score through denoising score entropy, generates from the predicted ratios. |
24
+ | `block.py` | `python lecture_4/block.py` | Trains masked diffusion inside blocks with a clean prefix, generates block by block. `--block` moves the dial between MDLM and autoregression. |
25
+ | `simplex.py` | `python lecture_4/simplex.py` | Trains a denoiser that reads a Dirichlet belief at every position, generates by thinning the belief, drawing an innovation and mixing the two. |
26
+ | `guidance.py` | `python lecture_4/guidance.py` | Trains a label-dropout denoiser and a classifier on noisy sequences, then samples unguided, with exact classifier guidance, with its gradient approximation, and classifier free. |
27
+ | `planner.py` | `python lecture_4/planner.py` | Trains with the planner weights inside the bound, samples the same weights in a uniform order and in the planner's order with a remasking corrector. |
28
+ | `search.py` | `python lecture_4/search.py` | Trains masked diffusion, then searches the tree of partial sequences under the h-transform and keeps a Pareto archive over two objectives. |
29
+
30
+ Two constructions of the chapter have no file here, and the chapter develops both in full. Per-token
31
+ schedules, MD4 and GenMD4, give every clean token its own rate `beta_a(t)` and change nothing but
32
+ the weight, which becomes `w_a(t) = -alpha_dot_t(a)/(1 - alpha_t(a))` inside the sum over tokens.
33
+ A2D2
34
+ lets the length change by adding an insertion jump beside the unmasking jump, which enlarges the
35
+ state space and keeps the master equation, the reversal and the rate loss exactly as they are.
36
+
37
+ Every file takes the shared flags of `cis6270.runner.common_parser`, so `--steps`, `--samples`,
38
+ `--sample-steps`, `--seed` and `--quiet` mean the same thing everywhere. `ctmc.py` trains nothing,
39
+ so its `--steps` is the number of Euler steps in the matrix-exponential comparison and its
40
+ `--samples` the number of simulated paths. `d3pm.py` takes
41
+ `T` from `--timesteps`, since the same number fixes the corruption and the ancestral sampler.
42
+ `search.py` reads `--sample-steps` as the length of one rollout, which is short because every leaf
43
+ of the tree is scored by its own rollouts.
44
+
45
+ ## What a default run produces
46
+
47
+ All numbers below come from `--seed 0` on a CPU. The worked numbers are exact and repeat on any
48
+ machine; the trained numbers move a little with the hardware's floating point, and the sample
49
+ statistics move with the seed. The training data have GC fraction 0.577 and motif fraction 0.777,
50
+ and a sampler drawing its bases uniformly would land near 0.05 on the motif, since ACGT fits in a
51
+ length-16 sequence at thirteen places and each one has probability `1/256`.
52
+
53
+ | File | Worked numbers it prints | Trained numbers |
54
+ | --- | --- | --- |
55
+ | `ctmc.py` | exit rate 4 out of A, `P(A, A) = 0.6` at `h = 0.1`, Euler step `(0.16, 0.22, 0.28, 0.34)`, `exp(tau R_pi)` at `0.625` and `0.125`, Euler products `0.480`, `0.570`, `0.616`, reverse rates `2` and `0.5` out of C | none |
56
+ | `mdlm.py` | `r = 0.5`, reverse law `(0.3, 0.2, 0, 0, 0.5)`, `w(0.5) = 2`, loss term `2.0996`, rate error `1.0217` | bound 1.25 nats per token, GC 0.56, motif 0.69 |
57
+ | `uniform.py` | kernel `0.625` and `0.125`, clean factor 5, posterior `(0.375, 0.625)`, conditional rates 5 and 0.2, posterior average **2**, model rates `2.76` and `1.16`, rate error `0.754` | rate loss 19.1 nats per sequence, GC 0.56, motif 0.50 |
58
+ | `d3pm.py` | `alpha_bar_2 = 0.56`, `Q_bar_2` at `0.67` and `0.11`, the step-1 posterior `(0.5795, 0.3523, 0.0341, 0.0341)`, Gaussian row `0.4088` and `0.0912`, `exp(0.3 R)` first row `(0.6747, 0.1964, 0.1122, 0.0167)` | bound 18.2 nats per sequence, GC 0.57, motif 0.72 |
59
+ | `sedd.py` | target ratio 2, score entropy `0.0107`, `0.3863`, `0.1891` | score entropy 18.8 nats per sequence, mean predicted ratio 1.26 toward a conditional target of 5, GC 0.55, motif 0.33 |
60
+ | `block.py` | worked bound `2.7567` nats on ACGTAC, `integral of w(t)(1 - alpha_t) = 1` | bound 1.21 nats per token at `L' = 4` and 1.29 at `L' = 1`, GC 0.57, motif 0.67 |
61
+ | `simplex.py` | `c_t = 8` and `c_s = 20`, parameters `(5, 1, 1, 1)` and `(17, 1, 1, 1)`, standard deviation `0.161` on A, increment `(12, 0, 0, 0)`, `W ~ Beta(8, 12)` with mean `0.4`, simulated standard deviation `0.0779` against the exact `0.0779` | cross-entropy 0.42 nats per token, GC 0.57, motif 0.34 |
62
+ | `guidance.py` | `h = (2, 1, 0.5, 0.5)`, guided rates `(1, 1.5, 0.5)`, at `gamma = 2` rates `(0.5, 4.5, 0.5)` with exit rate `5.5` and share `0.8182` on G | GC 0.58 unguided, 0.68 with the exact classifier, 0.63 with the gradient approximation, 0.74 classifier free |
63
+ | `planner.py` | reveals positions 3 and 6, remasks position 5, both corrector fluxes 1, PAPL weights `(0.4, 2.0, 0.6, 1.0)`, inner sums `2.9` and `2.12` | motif 0.61 in a uniform order against 0.86 in the planner's order, GC 0.58 and 0.51 |
64
+ | `search.py` | `h(z) = 0.35`, `h(z') = 0.75`, factor `2.143`, a rate of `1.2` becomes `2.571`, the archive keeps P1 and P2 | 144 rollouts scored, an archive of 3 sequences, best GC 0.88 and best motif match 1.0 |
65
+
66
+ A default run of `ctmc.py` takes about two seconds, and every other file takes between thirty and
67
+ forty seconds of processor time on one core. `--steps 20` runs each file in a few seconds.
68
+
69
+ `mdlm.py --steps 3000` lowers the bound from 1.25 to 1.22 nats per token in about three times the
70
+ time, and leaves the motif fraction inside the spread it already has across seeds, which at seeds
71
+ 0, 1 and 2 is 0.69, 0.45 and 0.72. The one-position bound of `block.py --block 1` is the exact
72
+ autoregressive log-likelihood with no slack, and at this budget it still measures above the
73
+ `L' = 4` bound, because the estimator draws one block of sixteen and multiplies by sixteen, which
74
+ is sixteen times noisier at the same number of draws, and because a denoiser restricted to a left
75
+ prefix has a harder prediction problem than one that reads both sides.
76
+ `sedd.py` sits below the other samplers on the motif because its sampler stops at the time floor,
77
+ as the list below says. `simplex.py --sample-steps 256` raises its motif fraction from 0.34 to
78
+ 0.38, so the reverse grid is not what holds that number down.
79
+
80
+ ## What is simplified
81
+
82
+ - The time weight `w(t) = 1/t` is unbounded at `t = 0`, so every file draws its times above a small
83
+ floor. The chapter's integrand is correct as written; the floor keeps one draw from dominating a
84
+ batch.
85
+ - The samplers that use tau-leaping rescale a step whose total jump probability would exceed one.
86
+ `uniform.py` then finishes with the exact posterior step down to `t = 0` that the chapter derives
87
+ for the `pi` family. `sedd.py` predicts ratios, with no posterior over clean tokens to step
88
+ through, so it stops at the time floor, where `alpha_t = 0.92` leaves about eight percent of the
89
+ positions redrawn from `pi`.
90
+ - `uniform.py` and `sedd.py` use the constant schedule `beta(t) = 4`, which is the schedule of the
91
+ chapter's worked example and reaches `alpha_t = 1/2` at `t = (log 2)/4`. Its survival probability
92
+ at `t = 1` is `0.018` and not zero, and that residue is the `KL(q_1 || p_1)` term of the bound.
93
+ - `d3pm.py` samples one step index per sequence and multiplies by `T`, which estimates the sum over
94
+ steps without evaluating all of it.
95
+ - `block.py` samples one block per sequence and multiplies by the number of blocks, for the same
96
+ reason. Training a block of length `L'` hides the suffix behind the mask in place of a
97
+ block-causal attention mask.
98
+ - `simplex.py` holds the concentration constant at `gamma = 1`, realizes the sum over clean tokens
99
+ by one categorical draw as the chapter does, and reads the final token as the corner the belief
100
+ has reached. Its loss is the chapter's cross-entropy, which bounds no likelihood, so its nats
101
+ per token do not compare with the bounds above.
102
+ - `guidance.py` uses a mean-pooling classifier on one-hot inputs, because the gradient
103
+ approximation needs a derivative with respect to the sequence and the label of this data is a
104
+ thresholded mean over positions.
105
+ - `planner.py` reveals the number of positions the schedule calls for at each step, which is the
106
+ deterministic version of the chapter's independent reveal draws, and it remasks a fixed count.
107
+ - `search.py` scores `--sample-steps` rollout steps per completion and expands a fixed number of
108
+ children, so the tree stays small enough to build in a few seconds.
lecture_4/block.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Block diffusion on DNA, with the block size as the dial between diffusion and autoregression.
2
+
3
+ The chain rule over blocks is exact, and each factor is a distribution over one block of tokens
4
+ conditioned on a clean prefix, which is the kind of distribution masked diffusion models. At one
5
+ block the bound is the MDLM loss, at blocks of a single position it is the autoregressive negative
6
+ log-likelihood with no slack, and every size in between interpolates.
7
+
8
+ Chapter 4, "Blocks between diffusion and autoregression" and "The block size as a dial between two
9
+ models". Time runs from t = 0 clean to t = 1 all masks, the survival probability is
10
+ alpha_t = 1 - t, and the rates R_t(x, y) act on probability rows, p_dot = p R. Trained on the
11
+ synthetic DNA of cis6270.data. Prints the worked bound of 2.7567 nats, the one-position identity,
12
+ and the sample statistics.
13
+
14
+ python lecture_4/block.py # train and generate with blocks of four
15
+ python lecture_4/block.py --block 1 # the autoregressive end of the dial
16
+ python lecture_4/block.py --steps 20 # a few seconds, for a first look
17
+ """
18
+
19
+ # %% 01. Imports and configuration
20
+ import math # The worked bound is a sum of logarithms.
21
+
22
+ import torch # Tensors, gradients and the categorical draws.
23
+ import torch.nn.functional as F # Cross-entropy at every position.
24
+
25
+ from cis6270.data import DNA_SIZE, decode_dna, dna_splits # ACGT, K = 4 and the shared sequences.
26
+ from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
27
+ from cis6270.nets import SequenceDenoiser, count_parameters # The denoiser d_theta(. | z, t).
28
+ from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
29
+
30
+ MASK = DNA_SIZE # A, C, G, T are 0 to 3, and token 4 is the mask m.
31
+ VOCAB = DNA_SIZE + 1 # The masked alphabet V_m has K + 1 = 5 symbols.
32
+ TIME_FLOOR = 1e-3 # The reveal rate w(t) = 1/t is unbounded at t = 0.
33
+
34
+
35
+ # %% 02. One block, with a clean prefix and a masked suffix
36
+ #
37
+ # The rate matrix acts only on the current block. Everything before it is clean and conditions the
38
+ # prediction, and everything after it is hidden, so the denoiser never reads the future.
39
+ def block_input(x0: torch.Tensor, block: torch.Tensor, size: int, t: torch.Tensor) -> torch.Tensor:
40
+ """The network input for one block: clean prefix, corrupted block, masked suffix. [B, L]."""
41
+ position = torch.arange(x0.shape[1], device=x0.device)[None, :] # [1, L] the column index.
42
+ start = (block * size)[:, None] # [B, 1] where the current block begins.
43
+ inside = (position >= start) & (position < start + size) # [B, L] the current block.
44
+ keep = torch.rand(x0.shape, device=x0.device) < (1.0 - t) # [B, L] the survival draw.
45
+ corrupted = torch.where(keep, x0, MASK) # [B, L] masking applied inside the block.
46
+ return torch.where(position < start, x0, torch.where(inside, corrupted, MASK)) # [B, L].
47
+
48
+
49
+ def block_loss(model: torch.nn.Module, x0: torch.Tensor, size: int) -> torch.Tensor:
50
+ """One Monte Carlo sample of the block bound, with one block and one time per sequence."""
51
+ blocks = math.ceil(x0.shape[1] / size) # B, the number of blocks the sequence splits into.
52
+ block = torch.randint(0, blocks, (x0.shape[0],), device=x0.device) # [B] which factor to sample.
53
+ t = torch.rand(x0.shape[0], device=x0.device).clamp(min=TIME_FLOOR) # [B] one noise level each.
54
+ z = block_input(x0, block, size, t[:, None]) # [B, L] the denoiser's input.
55
+ position = torch.arange(x0.shape[1], device=x0.device)[None, :] # [1, L].
56
+ start = (block * size)[:, None] # [B, 1].
57
+ inside = (position >= start) & (position < start + size) # [B, L] the current block.
58
+ logits = model(z, t) # [B, L, 5] the clean-token prediction at every position.
59
+ logits[..., MASK] = -1e9 # The zero-mask rule: no posterior mass belongs on m.
60
+ ce = F.cross_entropy(logits.transpose(1, 2), x0, reduction="none") # [B, L].
61
+ scored = ce * ((z == MASK) & inside) # [B, L] only the masked positions of the current block.
62
+ # Drawing one block uniformly estimates a sum over blocks, so the term is scaled by that count.
63
+ return (blocks * (1.0 / t)[:, None] * scored).sum(-1).mean() # Nats per sequence.
64
+
65
+
66
+ # %% 03. The block size as a dial
67
+ #
68
+ # At a block of one position the time integral collapses. The position is masked with probability
69
+ # 1 - alpha_t and weighted by w(t) = -alpha_dot/(1 - alpha_t), so the integrand is -alpha_dot times
70
+ # the cross-entropy, and the remaining integral is alpha_0 - alpha_1 = 1.
71
+ def one_position_integral(grid: int = 2048) -> float:
72
+ """The integral of w(t)(1 - alpha_t) over [0, 1], which the collapse makes exactly one."""
73
+ t = (torch.arange(grid).double() + 0.5) / grid # [grid] midpoints of a uniform partition.
74
+ return float(((1.0 / t) * t).mean()) # w(t)(1 - alpha_t) = 1 at every t on the linear schedule.
75
+
76
+
77
+ def one_position_estimate(probability: float, draws: int, generator) -> float:
78
+ """The same integral by Monte Carlo, which is how the training loss evaluates it."""
79
+ t = torch.rand(draws, generator=generator).clamp(min=TIME_FLOOR) # [N] uniform times.
80
+ masked = torch.rand(draws, generator=generator) < t # [N] the single position is masked or not.
81
+ return float(((1.0 / t) * masked * -math.log(probability)).mean()) # Should land on -log d.
82
+
83
+
84
+ # %% 04. Generating block by block
85
+ @torch.no_grad() # Generation needs no gradient tracking.
86
+ def sample(model: torch.nn.Module, batch: int, length: int, size: int,
87
+ steps: int = 32) -> torch.Tensor:
88
+ """Fill one block at a time by masked diffusion, with the finished blocks as a clean prefix."""
89
+ device = next(model.parameters()).device
90
+ z = torch.full((batch, length), MASK, device=device) # [B, L] every position starts masked.
91
+ ts = torch.linspace(1.0, TIME_FLOOR, steps + 1, device=device) # The reverse grid per block.
92
+ position = torch.arange(length, device=device)[None, :] # [1, L] the column index.
93
+ for start in range(0, length, size): # Blocks are completed in order, left to right.
94
+ inside = (position >= start) & (position < start + size) # [1, L] the current block.
95
+ for t, s in zip(ts[:-1], ts[1:]):
96
+ logits = model(z, t.expand(batch)) # [B, L, 5] the prefix conditions every prediction.
97
+ logits[..., MASK] = -1e9 # The zero-mask rule, at sampling time.
98
+ x0 = torch.distributions.Categorical(logits=logits).sample() # [B, L] candidate tokens.
99
+ unmask = torch.rand(z.shape, device=device) < (t - s) / t # [B, L] the reveal draw.
100
+ z = torch.where((z == MASK) & unmask & inside, x0, z) # Reveal inside this block only.
101
+ logits = model(z, ts[-1].expand(batch)) # The last step of the block runs down to s = 0.
102
+ logits[..., MASK] = -1e9
103
+ final = torch.distributions.Categorical(logits=logits).sample() # [B, L].
104
+ z = torch.where((z == MASK) & inside, final, z) # The block is clean before the next starts.
105
+ return z # [B, L] clean DNA.
106
+
107
+
108
+ # %% 05. The report
109
+ def main() -> None:
110
+ parser = common_parser(__doc__.split("\n")[0])
111
+ parser.add_argument("--block", type=int, default=4, help="L', the block length")
112
+ # Generation runs the whole reverse grid once per block, so --sample-steps stays at 16 while
113
+ # training rises to 1000 steps and the report measures 64 sequences, inside about a minute.
114
+ parser.set_defaults(steps=1000, sample_steps=16, samples=64)
115
+ args = parser.parse_args()
116
+ set_seed(args.seed)
117
+ device = pick_device(args.device)
118
+ talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
119
+
120
+ # Section 02, one draw of the bound on ACGTAC with blocks of two at t = 0.5.
121
+ weight = 1.0 / 0.5 # w(0.5) = 2 on the linear schedule.
122
+ contributions = [ # Block 1 masked twice, block 2 once, block 3 once.
123
+ weight * (-math.log(0.5) - math.log(0.8)),
124
+ weight * -math.log(0.7),
125
+ weight * -math.log(0.9),
126
+ ]
127
+ talk(f"worked block bound {[round(v, 4) for v in contributions]} total "
128
+ f"{sum(contributions):.4f} nats on ACGTAC")
129
+
130
+ # Section 03, the one-position identity that makes blocks of length one autoregressive.
131
+ generator = torch.Generator().manual_seed(args.seed) # A fixed stream for the estimate.
132
+ exact = one_position_integral() # The integral of w(t)(1 - alpha_t), which is one.
133
+ estimate = one_position_estimate(0.7, 20000, generator) # The same number by Monte Carlo.
134
+ talk(f"one-position dial integral {exact:.4f} Monte Carlo bound {estimate:.4f} against "
135
+ f"-log 0.7 = {-math.log(0.7):.4f}")
136
+
137
+ # Train the denoiser on the shared synthetic DNA at the chosen block size.
138
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
139
+ train_tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
140
+ valid_tokens = splits["valid_tokens"].to(device) # [M, L] held out for the bound below.
141
+ model = SequenceDenoiser(VOCAB, args.length, width=args.width).to(device)
142
+ blocks = math.ceil(args.length / args.block) # How many factors the chain rule has.
143
+ talk(f"device={device} parameters={count_parameters(model):,} "
144
+ f"blocks={blocks} of length {args.block}")
145
+
146
+ def step_loss(step: int) -> torch.Tensor:
147
+ rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device)
148
+ return block_loss(model, train_tokens[rows], args.block) # One clean batch into one scalar.
149
+
150
+ losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
151
+ with torch.no_grad():
152
+ bound = sum(float(block_loss(model, valid_tokens, args.block)) for _ in range(8)) / 8.0
153
+
154
+ torch.manual_seed(args.seed + 1) # Fix the reveal draws, so the samples repeat.
155
+ samples = sample(model, args.samples, args.length, args.block, args.sample_steps)
156
+ report = {
157
+ "worked bound on ACGTAC": sum(contributions),
158
+ "first block of the worked bound": contributions[0],
159
+ "integral of w(t)(1 - alpha_t)": exact,
160
+ "one-position Monte Carlo bound": estimate,
161
+ "blocks": blocks,
162
+ "block length": args.block,
163
+ "training bound, last 50": sum(losses[-50:]) / len(losses[-50:]),
164
+ "validation bound, nats/seq": bound,
165
+ "bound, nats per token": bound / args.length,
166
+ "GC fraction": gc_fraction(samples),
167
+ "GC fraction in the data": gc_fraction(train_tokens),
168
+ "motif fraction": motif_fraction(samples),
169
+ "motif fraction in the data": motif_fraction(train_tokens),
170
+ "distinct samples": unique_fraction(samples),
171
+ "letter entropy, nats": token_entropy(samples, DNA_SIZE),
172
+ }
173
+ print_report(f"block: diffusion in blocks of {args.block}", report, decode_dna(samples))
174
+ save_run(args.out, model=model, config=vars(args), report=report,
175
+ losses=losses, samples=decode_dna(samples))
176
+
177
+
178
+ # %% 06. Run the script
179
+ if __name__ == "__main__": # Execute when launched directly.
180
+ main() # Work the block bound, train at one block size, and generate block by block.
lecture_4/ctmc.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Continuous-time Markov chains on one DNA base, the mathematics with no learning in it.
2
+
3
+ A rate matrix says what a base can do in the next instant, and the master equation, the matrix
4
+ exponential, the stationary distribution and the time reversal all follow from it. Nothing here is
5
+ trained: every number this file prints is a number the chapter works by hand.
6
+
7
+ Chapter 4, "The two rules a rate matrix obeys" through "Running the jumps backward". Time runs from
8
+ t = 0 clean to t = 1 all masks, the survival probability is alpha_t, and the rates R_t(x, y) act on
9
+ probability rows, p_dot = p R. Prints every worked quantity of those sections.
10
+
11
+ python lecture_4/ctmc.py # the worked numbers, 10 Euler steps
12
+ python lecture_4/ctmc.py --steps 20 # a finer Euler grid in Section 09
13
+ """
14
+
15
+ # %% 01. Imports and configuration
16
+ import math # Logarithms and exponentials of the worked numbers.
17
+
18
+ import torch # Matrices, eigenvalues and the matrix exponential.
19
+
20
+ from cis6270.data import DNA, DNA_SIZE # The alphabet ACGT and K = 4.
21
+ from cis6270.runner import common_parser, print_report, save_run, set_seed # Flags and the report.
22
+
23
+ MASK_STATES = DNA_SIZE + 1 # A, C, G, T and m, the five states of the masked alphabet.
24
+
25
+
26
+ # %% 02. The two rules a rate matrix obeys
27
+ def rate_matrix(off_diagonal: torch.Tensor) -> torch.Tensor:
28
+ """Fill the diagonal of a nonnegative array so that R_t(x, x) = -sum_{y != x} R_t(x, y)."""
29
+ R = off_diagonal.clone().double() # [K, K] the chosen jump rates.
30
+ R.fill_diagonal_(0.0) # The diagonal adds no information of its own.
31
+ return R - torch.diag(R.sum(dim=1)) # [K, K] every row now sums to zero.
32
+
33
+
34
+ def obeys_the_two_rules(R: torch.Tensor, tol: float = 1e-9) -> bool:
35
+ """Nonnegative off the diagonal, and zero row sums, which is the definition."""
36
+ off = R - torch.diag(torch.diag(R)) # [K, K] the diagonal removed.
37
+ return bool((off >= -tol).all()) and bool(R.sum(dim=1).abs().max() < tol)
38
+
39
+
40
+ def exit_rates(R: torch.Tensor) -> torch.Tensor:
41
+ """lambda_t(x) = -R_t(x, x), the total rate of leaving each state."""
42
+ return -torch.diag(R) # [K] one exit rate per state.
43
+
44
+
45
+ # %% 03. One row of a rate matrix for DNA
46
+ #
47
+ # A jumps to C at rate 2 and to G and to T at rate 1 each. The chapter gives that one row; the
48
+ # other three repeat the pattern with the next base in ACGT order taking the rate 2.
49
+ DNA_RATES = rate_matrix(torch.tensor([
50
+ [0.0, 2.0, 1.0, 1.0], # A: rate 2 toward C, rate 1 toward G and T.
51
+ [1.0, 0.0, 2.0, 1.0], # C: rate 2 toward G.
52
+ [1.0, 1.0, 0.0, 2.0], # G: rate 2 toward T.
53
+ [2.0, 1.0, 1.0, 0.0], # T: rate 2 toward A.
54
+ ]))
55
+
56
+
57
+ def short_interval_row(R: torch.Tensor, state: int, h: float) -> torch.Tensor:
58
+ """One row of P_{t,t+h} = I + h R_t + o(h), the first-order transition probabilities."""
59
+ identity_row = torch.zeros(R.shape[0], dtype=R.dtype) # [K] the row of I at this state.
60
+ identity_row[state] = 1.0 # Staying put is the only certainty at h = 0.
61
+ return identity_row + h * R[state] # [K] probabilities, valid while the diagonal stays positive.
62
+
63
+
64
+ def largest_valid_step(R: torch.Tensor) -> float:
65
+ """The bound h * sum_{y != x} R_t(x, y) <= 1, read at the state with the largest exit rate."""
66
+ return float(1.0 / exit_rates(R).max()) # One bad state is enough to give a negative entry.
67
+
68
+
69
+ # %% 04. Waiting times and destinations
70
+ #
71
+ # A state x holds for an exponential time of rate lambda(x), because S(s + h) = S(s)(1 - lambda h)
72
+ # gives dS/ds = -lambda S. The destination is then the row of rates normalized to sum to one.
73
+ def sample_path(R: torch.Tensor, start: int, horizon: float, generator) -> list[tuple[float, int]]:
74
+ """Gillespie simulation: an exponential wait, then a destination drawn from the normalized row."""
75
+ time_now, state, path = 0.0, start, [(0.0, start)] # The path begins at the starting base.
76
+ while True:
77
+ rate = float(exit_rates(R)[state]) # lambda(x) for the base we are sitting on.
78
+ u = float(torch.rand((), generator=generator)) # One uniform variate for the wait.
79
+ time_now += -math.log(u) / rate # Inverting S(s) = exp(-lambda s) at u.
80
+ if time_now > horizon:
81
+ return path # The last jump falls outside the interval we are watching.
82
+ row = R[state].clamp(min=0.0) # [K] the nonnegative rates out of this base.
83
+ state = int(torch.multinomial(row / row.sum(), 1, generator=generator)) # The destination.
84
+ path.append((time_now, state)) # Record when the jump happened and where it went.
85
+
86
+
87
+ # %% 05. The master equation
88
+ def master_equation(p: torch.Tensor, R: torch.Tensor) -> torch.Tensor:
89
+ """p_dot = p R, the Kolmogorov forward equation written on probability rows."""
90
+ return p @ R # [K] the y-th entry collects the y-th column of R, every rate pointing into y.
91
+
92
+
93
+ def arrivals_and_departures(p: torch.Tensor, R: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
94
+ """The same derivative split into the flux j_t(x -> y) arriving at y and the flux leaving y."""
95
+ flux = p[:, None] * R # [K, K] j_t(x -> y) = p_t(x) R_t(x, y), off the diagonal.
96
+ arriving = flux.sum(dim=0) - torch.diag(flux) # [K] sum over x != y of p_t(x) R_t(x, y).
97
+ leaving = -torch.diag(flux) # [K] p_t(y) times the exit rate of y.
98
+ return arriving, leaving # Their difference is the master equation.
99
+
100
+
101
+ # %% 06. A worked evolution of four DNA probabilities
102
+ #
103
+ # Every base jumps to every other at rate 1, so each diagonal entry is -3 and each coordinate obeys
104
+ # p_dot(y) = 1 - 4 p(y), whose solution is 1/4 + (p_0(y) - 1/4) exp(-4t).
105
+ UNIT_RATES = rate_matrix(torch.ones(DNA_SIZE, DNA_SIZE)) # [4, 4] all pairwise rates equal to 1.
106
+ START = torch.tensor([0.1, 0.2, 0.3, 0.4], dtype=torch.float64) # p_t over (A, C, G, T).
107
+
108
+
109
+ def exact_evolution(p0: torch.Tensor, t: float) -> torch.Tensor:
110
+ """The closed-form solution of p_dot(y) = 1 - 4 p(y) for the all-ones rate matrix."""
111
+ return 0.25 + (p0 - 0.25) * math.exp(-4.0 * t) # [K] each coordinate decays toward 1/4.
112
+
113
+
114
+ # %% 07. The matrix exponential and its composition over two intervals
115
+ def transition_matrix(R: torch.Tensor, elapsed: float) -> torch.Tensor:
116
+ """P_{0,t} = exp(t R), the solution of the master equation for a fixed rate matrix."""
117
+ return torch.matrix_exp(elapsed * R) # [K, K] rows sum to one and no entry is negative.
118
+
119
+
120
+ def euler_product(R: torch.Tensor, elapsed: float, n: int) -> torch.Tensor:
121
+ """(I + (t/n) R)^n, the product of n short-interval steps, which approaches exp(t R)."""
122
+ step = torch.eye(R.shape[0], dtype=R.dtype) + (elapsed / n) * R # [K, K] one first-order step.
123
+ return torch.linalg.matrix_power(step, n) # [K, K] the steps compose by matrix multiplication.
124
+
125
+
126
+ # %% 08. The R_pi family and its closed-form kernel
127
+ #
128
+ # At rate one the current token is discarded and a fresh one drawn from pi, so every row of the rate
129
+ # matrix is pi with the current state subtracted. Because M = 1 pi^T is idempotent, the exponential
130
+ # collapses to a mixture of staying put and drawing afresh.
131
+ def r_pi(pi: torch.Tensor) -> torch.Tensor:
132
+ """R_pi = 1 pi^T - I, the one-free-distribution family."""
133
+ return torch.ones(len(pi), 1, dtype=pi.dtype) @ pi[None, :] - torch.eye(len(pi), dtype=pi.dtype)
134
+
135
+
136
+ def pi_kernel(pi: torch.Tensor, alpha: float) -> torch.Tensor:
137
+ """Q(alpha) = alpha I + (1 - alpha) 1 pi^T, the kernel at survival probability alpha."""
138
+ M = torch.ones(len(pi), 1, dtype=pi.dtype) @ pi[None, :] # [K, K] every row equals pi.
139
+ return alpha * torch.eye(len(pi), dtype=pi.dtype) + (1.0 - alpha) * M # [K, K] a convex mixture.
140
+
141
+
142
+ # %% 09. Eigenvalues, stationarity and detailed balance
143
+ def detailed_balance_gap(R: torch.Tensor, pi: torch.Tensor) -> float:
144
+ """The largest mismatch in pi(x) R(x, y) = pi(y) R(y, x), zero when the pair is reversible."""
145
+ flux = pi[:, None] * R # [K, K] the stationary flux along each edge.
146
+ return float((flux - flux.T).abs().max()) # Zero exactly when every edge balances.
147
+
148
+
149
+ CYCLE = rate_matrix(torch.tensor([ # A -> C -> G -> A at rate 1, with no jumps the other way.
150
+ [0.0, 1.0, 0.0],
151
+ [0.0, 0.0, 1.0],
152
+ [1.0, 0.0, 0.0],
153
+ ]))
154
+
155
+
156
+ # %% 10. Time reversal
157
+ def reverse_rates(R: torch.Tensor, p: torch.Tensor) -> torch.Tensor:
158
+ """R_hat_t(y, x) = R_t(x, y) p_t(x) / p_t(y), the rates of the process watched backward."""
159
+ reversed_rates = (R * p[:, None] / p[None, :]).T # [K, K] transpose moves (x, y) to (y, x).
160
+ reversed_rates.fill_diagonal_(0.0) # The diagonal is fixed by the row-sum rule, not by the ratio.
161
+ return reversed_rates - torch.diag(reversed_rates.sum(dim=1)) # [K, K] a rate matrix again.
162
+
163
+
164
+ # %% 11. The report
165
+ def main() -> None:
166
+ parser = common_parser(__doc__.split("\n")[0])
167
+ # Nothing trains here, so --steps is the Euler grid of Section 09 and --samples the number of
168
+ # Gillespie paths of Section 04. The flags that describe a network go unread.
169
+ parser.set_defaults(steps=10, samples=512)
170
+ args = parser.parse_args()
171
+ set_seed(args.seed) # The same seed gives the same sampled path and the same histogram.
172
+ generator = torch.Generator().manual_seed(args.seed) # Local stream for the simulations.
173
+ talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
174
+
175
+ # Sections 02 and 03, the row for A and its first-order transition probabilities.
176
+ row_probabilities = short_interval_row(DNA_RATES, 0, 0.1) # [4] at h = 0.1.
177
+ exact_stay = math.exp(-4.0 * 0.1) # The probability of never leaving A over that interval.
178
+ talk(f"rate row for A {[round(float(v), 2) for v in DNA_RATES[0]]} rules hold "
179
+ f"{obeys_the_two_rules(DNA_RATES)} exit rate {float(exit_rates(DNA_RATES)[0]):.0f}")
180
+ talk(f"P(A, .) at h = 0.1 {[round(float(v), 2) for v in row_probabilities]} "
181
+ f"exact stay {exact_stay:.4f} valid while h <= {largest_valid_step(DNA_RATES):.2f}, "
182
+ f"since at h = 0.3 the staying entry is "
183
+ f"{float(short_interval_row(DNA_RATES, 0, 0.3)[0]):.1f}")
184
+
185
+ # Section 04, the waiting time and the destination, first from the two uniforms of the chapter.
186
+ wait = -math.log(0.42) / 4.0 # u = 0.42 inverted through S(s) = exp(-4s).
187
+ destination = DNA[int(torch.cumsum(DNA_RATES[0].clamp(min=0) / 4.0, 0).lt(0.31).sum())]
188
+ path = sample_path(DNA_RATES, 0, 1.0, generator) # One sampled trajectory over the unit interval.
189
+ ends = torch.tensor([sample_path(DNA_RATES, 0, 1.0, generator)[-1][1]
190
+ for _ in range(args.samples)]) # [N] the base each path finishes on.
191
+ histogram = torch.bincount(ends, minlength=DNA_SIZE).double() / args.samples # [4] proportions.
192
+ exact_end = transition_matrix(DNA_RATES, 1.0)[0] # [4] the exact law of the base at t = 1.
193
+ talk(f"wait from u = 0.42 {wait:.4f} destination from v = 0.31 {destination} "
194
+ f"mean {1 / 4:.2f} median {math.log(2) / 4:.4f}")
195
+ talk(f"sampled path {' -> '.join(f'{DNA[s]}@{time:.3f}' for time, s in path)}")
196
+ talk(f"{args.samples} paths at t = 1 {[round(float(v), 3) for v in histogram]} against the "
197
+ f"exact {[round(float(v), 3) for v in exact_end]}")
198
+
199
+ # Sections 05 and 06, the master equation and the worked evolution.
200
+ derivative = master_equation(START, UNIT_RATES) # [4] p_dot = (0.6, 0.2, -0.2, -0.6).
201
+ arriving, leaving = arrivals_and_departures(START, UNIT_RATES) # The two halves of that number.
202
+ euler_step = START + 0.1 * derivative # One step of size h = 0.1, inside the bound h <= 1/3.
203
+ talk(f"p_dot at A {float(arriving[0]):.1f} in minus {float(leaving[0]):.1f} out "
204
+ f"= {float(derivative[0]):.1f} p_dot {[round(float(v), 1) for v in derivative]}")
205
+ talk(f"Euler step h = 0.1 {[round(float(v), 2) for v in euler_step]} exact "
206
+ f"{[round(float(v), 4) for v in exact_evolution(START, 0.1)]} "
207
+ f"total change {float(derivative.sum()):.0e}")
208
+ talk(f"exact at t = 1 {[round(float(v), 4) for v in exact_evolution(START, 1.0)]}")
209
+
210
+ # Section 07, the matrix exponential and the Chapman-Kolmogorov composition.
211
+ whole = transition_matrix(UNIT_RATES, 0.7) # P_{s,u} over an interval of length 0.7.
212
+ first, second = transition_matrix(UNIT_RATES, 0.3), transition_matrix(UNIT_RATES, 0.4)
213
+ halves = first @ second # P_{s,t} P_{t,u}, the two intervals composed.
214
+ talk(f"exp(tR) rows sum to {float(whole.sum(1).min()):.6f}, smallest entry "
215
+ f"{float(whole.min()):.6f}, composition gap {float((whole - halves).abs().max()):.2e}")
216
+
217
+ # Section 08, the R_pi family, its closed form and the multiplication of survival probabilities.
218
+ uniform = torch.full((DNA_SIZE,), 1.0 / DNA_SIZE, dtype=torch.float64) # pi uniform over ACGT.
219
+ tau = math.log(2.0) # The clock the linear schedule reaches at t = 0.5, where alpha = 1/2.
220
+ closed_form = pi_kernel(uniform, math.exp(-tau)) # alpha I + (1 - alpha) 1 pi^T.
221
+ series = transition_matrix(r_pi(uniform), tau) # The same matrix from the series.
222
+ composed = pi_kernel(uniform, 0.8) @ pi_kernel(uniform, 0.5 / 0.8) # Q(0.8) Q(0.625).
223
+ talk(f"closed form vs exp gap {float((closed_form - series).abs().max()):.2e} "
224
+ f"Q(0.8) Q(0.625) at (A, A) {float(composed[0, 0]):.5f} = Q(0.5) entry "
225
+ f"{float(pi_kernel(uniform, 0.5)[0, 0]):.5f}")
226
+
227
+ # Section 09, the matrix exponential worked for DNA, and the masking kernel beside it.
228
+ euler_entries = {n: float(euler_product(r_pi(uniform), tau, n)[0, 0]) for n in (1, 2, args.steps)}
229
+ mask_pi = torch.zeros(MASK_STATES, dtype=torch.float64) # pi = delta_m on the five states.
230
+ mask_pi[DNA_SIZE] = 1.0 # All of the target probability sits on the mask.
231
+ mask_kernel = pi_kernel(mask_pi, 0.5) # [5, 5] the masking kernel at alpha = 1/2.
232
+ talk(f"exp(tau R_pi) diagonal {float(closed_form[0, 0]):.3f}, off-diagonal "
233
+ f"{float(closed_form[0, 1]):.3f}, Euler " +
234
+ ", ".join(f"n={n}: {v:.3f}" for n, v in euler_entries.items()) +
235
+ f"; masking A row {[round(float(v), 2) for v in mask_kernel[0]]}, m row "
236
+ f"{[round(float(v), 2) for v in mask_kernel[DNA_SIZE]]}")
237
+
238
+ # Section 10, the eigenvalues and the stationary distribution, then detailed balance.
239
+ eigenvalues = sorted(float(v.real) for v in torch.linalg.eigvals(r_pi(uniform))) # 0 and -1.
240
+ cycle_pi = torch.full((3,), 1.0 / 3.0, dtype=torch.float64) # Uniform over the three bases.
241
+ talk(f"eigenvalues of R_pi {[round(v, 4) for v in eigenvalues]}, pi R_pi "
242
+ f"{float((uniform @ r_pi(uniform)).abs().max()):.2e}, uniform flux "
243
+ f"{float((uniform[:, None] * r_pi(uniform))[0, 1]):.4f} each way with gap "
244
+ f"{detailed_balance_gap(r_pi(uniform), uniform):.2e}")
245
+ talk(f"cycle of three stationary gap {float((cycle_pi @ CYCLE).abs().max()):.2e}, "
246
+ f"edge gap {detailed_balance_gap(CYCLE, cycle_pi):.4f}")
247
+
248
+ # Section 11, the reverse rates and the marginals they retrace.
249
+ marginal = torch.tensor([0.5, 0.25, 0.125, 0.125], dtype=torch.float64) # p_t at alpha_t = 1/2.
250
+ unit_forward = rate_matrix(torch.ones(DNA_SIZE, DNA_SIZE)) # Forward rate 1 between any two bases.
251
+ backward = reverse_rates(unit_forward, marginal) # [4, 4] R_hat_t(y, x).
252
+ talk(f"reverse row for C {[round(float(v), 3) for v in backward[1]]} exit rate "
253
+ f"{float(exit_rates(backward)[1]):.1f} mean holding time "
254
+ f"{1 / float(exit_rates(backward)[1]):.4f}")
255
+
256
+ report = {
257
+ "exit rate out of A": float(exit_rates(DNA_RATES)[0]),
258
+ "P(A, A) at h = 0.1": float(row_probabilities[0]),
259
+ "largest valid step": largest_valid_step(DNA_RATES),
260
+ "wait from u = 0.42": wait,
261
+ "p_dot at A": float(derivative[0]),
262
+ "Euler step at A": float(euler_step[0]),
263
+ "composition gap": float((whole - halves).abs().max()),
264
+ "exp(tau R_pi) at (A, A)": float(closed_form[0, 0]),
265
+ "exp(tau R_pi) off diagonal": float(closed_form[0, 1]),
266
+ f"Euler product, n = {args.steps}": euler_entries[args.steps],
267
+ "detailed balance gap, cycle": detailed_balance_gap(CYCLE, cycle_pi),
268
+ "reverse rate C to A": float(backward[1, 0]),
269
+ "reverse rate C to G": float(backward[1, 2]),
270
+ "p R_hat plus p R": float((marginal @ backward + marginal @ unit_forward).abs().max()),
271
+ }
272
+ print_report("ctmc: rates, marginals and their reversal", report)
273
+ save_run(args.out, config=vars(args), report=report)
274
+
275
+
276
+ # %% 12. Run the script
277
+ if __name__ == "__main__": # Execute when launched directly.
278
+ main() # Work every number of the sections above.
lecture_4/d3pm.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """D3PM on DNA, discrete time with one transition matrix per step.
2
+
3
+ Corruption multiplies a one-hot token by a transition matrix once per step, and the product of
4
+ those matrices stays inside the pi family, so the cumulative kernel is closed form. Reversing one
5
+ finite step is Bayes' rule on three factors, and the continuous-time rate matrices of the rest of
6
+ the chapter are the limit of these products.
7
+
8
+ Chapter 4, "Discrete time, as in D3PM", "Reversing one finite step" and "Rate matrices chosen for
9
+ the data". Time runs from t = 0 clean to t = T noise, the cumulative survival probability is
10
+ alpha_bar_t, and rates act on probability rows, p_dot = p R. Trained on the synthetic DNA of
11
+ cis6270.data. Prints the worked cumulative kernel, the worked reverse step, and the sample
12
+ statistics.
13
+
14
+ python lecture_4/d3pm.py # train and generate
15
+ python lecture_4/d3pm.py --steps 20 # a few seconds, for a first look
16
+ """
17
+
18
+ # %% 01. Imports and configuration
19
+ import math # The continuous-time limit is an exponential.
20
+
21
+ import torch # Tensors, gradients and the categorical draws.
22
+
23
+ from cis6270.data import DNA_SIZE, decode_dna, dna_splits # ACGT, K = 4 and the shared sequences.
24
+ from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
25
+ from cis6270.nets import SequenceDenoiser, count_parameters # The denoiser over clean tokens.
26
+ from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
27
+
28
+ AUXILIARY = 0.01 # lambda_aux, the coefficient of the cross-entropy D3PM adds to the bound.
29
+ UNIFORM = torch.full((DNA_SIZE,), 1.0 / DNA_SIZE) # pi uniform over the four bases.
30
+
31
+
32
+ # %% 02. The transition matrices and their product
33
+ #
34
+ # Q_t = (1 - beta_t) I + beta_t 1 pi^T is the one-step version of the pi family, and two members
35
+ # multiply to a third whose survival probability is the product of the two.
36
+ def one_step(beta: float) -> torch.Tensor:
37
+ """Q_t, with beta the probability of discarding the token and redrawing from pi. Returns [K, K]."""
38
+ return (1.0 - beta) * torch.eye(DNA_SIZE) + beta * UNIFORM.expand(DNA_SIZE, DNA_SIZE)
39
+
40
+
41
+ def cumulative(alpha_bar: float) -> torch.Tensor:
42
+ """Q_bar_t = alpha_bar_t I + (1 - alpha_bar_t) 1 pi^T, the product of the first t matrices."""
43
+ return alpha_bar * torch.eye(DNA_SIZE) + (1.0 - alpha_bar) * UNIFORM.expand(DNA_SIZE, DNA_SIZE)
44
+
45
+
46
+ def schedule(timesteps: int) -> tuple[torch.Tensor, torch.Tensor]:
47
+ """A linear cumulative survival alpha_bar_t = 1 - t/T, and the per-step beta_t it implies."""
48
+ alpha_bar = 1.0 - torch.arange(timesteps + 1).to(torch.get_default_dtype()) / timesteps # [T + 1] with alpha_bar_0 = 1.
49
+ beta = 1.0 - alpha_bar[1:] / alpha_bar[:-1].clamp(min=1e-12) # [T] the redraw probability.
50
+ return alpha_bar, beta # The two descriptions of the same corruption.
51
+
52
+
53
+ # %% 03. Reversing one finite step
54
+ #
55
+ # The numerator holds the two halves of the path through the candidate x_{t-1}, one from the clean
56
+ # token forward to the candidate and one from the candidate forward to the observation.
57
+ def exact_posterior(observed: torch.Tensor, clean_distribution: torch.Tensor,
58
+ step_matrix: torch.Tensor, previous: torch.Tensor) -> torch.Tensor:
59
+ """q(x_{t-1} | x_t, x_0) when clean_distribution is a point mass, averaged over it otherwise.
60
+
61
+ observed: [B, L] token ids. clean_distribution: [B, L, K] over candidate clean tokens.
62
+ step_matrix and previous: [B, K, K], one Q_t and one Q_bar_{t-1} per sequence.
63
+ Returns [B, L, K], normalized over the candidate token at step t - 1.
64
+ """
65
+ length = observed.shape[1]
66
+ columns = step_matrix[:, None].expand(-1, length, -1, -1) # [B, L, K, K] the step matrix.
67
+ to_observation = columns.gather( # [B, L, K] the column of Q_t at the observed token.
68
+ 3, observed[:, :, None, None].expand(-1, -1, DNA_SIZE, 1)).squeeze(-1)
69
+ from_clean = torch.bmm(clean_distribution, previous) # [B, L, K] the row of Q_bar_{t-1}.
70
+ joint = to_observation * from_clean # [B, L, K] the two halves of the path, multiplied.
71
+ return joint / joint.sum(-1, keepdim=True).clamp(min=1e-20) # [B, L, K] a distribution.
72
+
73
+
74
+ # %% 04. Rate matrices chosen for the data
75
+ #
76
+ # Nothing in the five facts requires that every pair of tokens be treated alike. A discretized
77
+ # Gaussian keeps a pixel near its own intensity, and a nearest-neighbour graph does the same for
78
+ # tokens.
79
+ def gaussian_row(levels: int = 5, center: int = 2, sigma: float = 1.0) -> torch.Tensor:
80
+ """The off-diagonal weights out of one intensity level, normalized to a total rate of one."""
81
+ offsets = torch.arange(levels).to(torch.get_default_dtype()) - center # [levels] the distance to each level.
82
+ weights = torch.exp(-offsets ** 2 / (2.0 * sigma ** 2)) # [levels] nearby levels weigh more.
83
+ weights[center] = 0.0 # The diagonal is fixed by the row-sum rule.
84
+ return weights / weights.sum() # [levels] a row of rates summing to one.
85
+
86
+
87
+ def graph_rates(adjacency: torch.Tensor) -> torch.Tensor:
88
+ """R(i, j) = A_ij off the diagonal and R(i, i) = -sum_l A_il, from a token-similarity graph."""
89
+ R = adjacency.clone() # [K, K] the graph weights.
90
+ R.fill_diagonal_(0.0) # Only the off-diagonal entries are rates.
91
+ return R - torch.diag(R.sum(dim=1)) # [K, K] every row sums to zero.
92
+
93
+
94
+ # %% 05. The discrete-time bound
95
+ #
96
+ # The loss is the variational bound of Section "A variational bound for the whole path", one KL
97
+ # divergence per step and per position, plus the auxiliary cross-entropy on the clean tokens.
98
+ def d3pm_loss(model: torch.nn.Module, x0: torch.Tensor, matrices: tuple) -> torch.Tensor:
99
+ """One sample of L_vb + lambda_aux CE, with one step index and one corruption per sequence."""
100
+ steps, cumulatives, alpha_bar = matrices # [T, K, K], [T + 1, K, K] and [T + 1].
101
+ timesteps = len(steps)
102
+ index = torch.randint(1, timesteps + 1, (x0.shape[0],), device=x0.device) # [B] the step t.
103
+ probabilities = cumulatives[index][ # [B, L, K] the row of Q_bar_t at each clean token.
104
+ torch.arange(len(x0), device=x0.device)[:, None], x0]
105
+ xt = torch.distributions.Categorical(probs=probabilities).sample() # [B, L] the noisy tokens.
106
+
107
+ time = index.to(torch.get_default_dtype()) / timesteps # [B] the step index as a time in [0, 1] for the network.
108
+ denoiser = model(xt, time).softmax(-1) # [B, L, K] the posterior over clean tokens.
109
+ one_hot = torch.nn.functional.one_hot(x0, DNA_SIZE).to(torch.get_default_dtype()) # [B, L, K] the true clean token.
110
+ step_matrix, previous = steps[index - 1], cumulatives[index - 1] # [B, K, K] each.
111
+ target = exact_posterior(xt, one_hot, step_matrix, previous) # [B, L, K] the exact reverse step.
112
+ predicted = exact_posterior(xt, denoiser, step_matrix, previous) # [B, L, K] the model's own.
113
+ divergence = (target * (target.clamp(min=1e-20) # KL(q || p_theta) at every position.
114
+ / predicted.clamp(min=1e-20)).log()).sum((1, 2)) # [B].
115
+ cross_entropy = -(one_hot * denoiser.clamp(min=1e-20).log()).sum((1, 2)) # [B] the auxiliary term.
116
+ # Drawing one step index uniformly estimates a sum of T terms, so the divergence is scaled by T.
117
+ return (timesteps * divergence + AUXILIARY * cross_entropy).mean() # Nats per sequence.
118
+
119
+
120
+ # %% 06. Ancestral sampling
121
+ @torch.no_grad() # Generation needs no gradient tracking.
122
+ def sample(model: torch.nn.Module, batch: int, length: int, matrices: tuple) -> torch.Tensor:
123
+ """Walk the chain from x_T drawn from pi down to x_0. Returns [B, L] over ACGT."""
124
+ steps, cumulatives, _ = matrices
125
+ device = next(model.parameters()).device
126
+ timesteps = len(steps)
127
+ x = torch.distributions.Categorical( # [B, L] the prior, which is pi at every position.
128
+ probs=UNIFORM.to(device).expand(batch, length, DNA_SIZE)).sample()
129
+ for t in range(timesteps, 0, -1): # One finite reverse step at a time.
130
+ time = torch.full((batch,), t / timesteps, device=device) # [B] the step as a time.
131
+ denoiser = model(x, time).softmax(-1) # [B, L, K] the prediction of the clean token.
132
+ posterior = exact_posterior( # [B, L, K] the reverse step, averaged over the denoiser.
133
+ x, denoiser, steps[t - 1].expand(batch, -1, -1), cumulatives[t - 1].expand(batch, -1, -1))
134
+ x = torch.distributions.Categorical(probs=posterior).sample() # [B, L] the earlier tokens.
135
+ return x # [B, L] clean DNA.
136
+
137
+
138
+ # %% 07. The report
139
+ def main() -> None:
140
+ parser = common_parser(__doc__.split("\n")[0])
141
+ parser.add_argument("--timesteps", type=int, default=32, help="T, the number of discrete steps")
142
+ # T fixes both the corruption and the ancestral sampler, so this file reads it from --timesteps
143
+ # and leaves --sample-steps alone. A default run trains 1000 steps on 64 sequences, inside
144
+ # about a minute.
145
+ parser.set_defaults(steps=1000, samples=64)
146
+ args = parser.parse_args()
147
+ set_seed(args.seed)
148
+ device = pick_device(args.device)
149
+ talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
150
+
151
+ # Section 02, the worked cumulative kernel with beta_1 = 0.2 and beta_2 = 0.3.
152
+ first, second = one_step(0.2), one_step(0.3) # [K, K] the two transition matrices.
153
+ product = first @ second # [K, K] the cumulative kernel after two steps.
154
+ alpha_bar_two = 0.8 * 0.7 # The product of the per-step survival probabilities.
155
+ talk(f"Q_bar_2 alpha_bar {alpha_bar_two:.2f} diagonal {float(product[0, 0]):.2f} "
156
+ f"off-diagonal {float(product[0, 1]):.2f} closed-form gap "
157
+ f"{float((product - cumulative(alpha_bar_two)).abs().max()):.2e}")
158
+
159
+ # Section 02 again, the continuous-time limit of the same product at a constant rate.
160
+ limits = {n: (1.0 - 1.0 / n) ** n for n in (10, 100, 1000)} # The products climb toward exp(-1).
161
+ talk("constant rate 1 " + " ".join(f"T={n}: {v:.4f}" for n, v in limits.items()) +
162
+ f" exp(-1) = {math.exp(-1):.4f}")
163
+
164
+ # Section 03, reversing one finite step from an observed C under a clean A.
165
+ clean_a = torch.tensor([[[1.0, 0.0, 0.0, 0.0]]]) # [1, 1, K] the clean base A.
166
+ observed_c = torch.tensor([[1]]) # [1, 1] the base observed at step 2.
167
+ history = exact_posterior(observed_c, clean_a, second[None], first[None])[0, 0] # [K] at step 1.
168
+ talk(f"observed C, clean A column of Q_2 {[round(float(v), 3) for v in second.T[1]]} row of "
169
+ f"Q_bar_1 {[round(float(v), 2) for v in first[0]]}")
170
+ talk(f"q(x_1 | x_2, x_0) {[round(float(v), 4) for v in history]} "
171
+ f"evidence {float(product[0, 1]):.2f}")
172
+
173
+ # Section 04, two rate matrices built for the data they corrupt.
174
+ neighbours = gaussian_row() # [5] the row out of a mid-grey pixel.
175
+ adjacency = torch.tensor([ # A to C, C to G and G to T at weight 1, A to G at weight 0.5.
176
+ [0.0, 1.0, 0.5, 0.0], [1.0, 0.0, 1.0, 0.0],
177
+ [0.5, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0]])
178
+ R = graph_rates(adjacency) # [K, K] the rate matrix of the token graph.
179
+ graph_kernel = torch.matrix_exp(0.3 * R) # [K, K] its transition matrix at clock 0.3.
180
+ flux = UNIFORM[:, None] * R # [K, K] the stationary flux along each edge.
181
+ talk(f"gaussian row neighbour {float(neighbours[1]):.4f} two away "
182
+ f"{float(neighbours[0]):.4f} ratio {float(neighbours[1] / neighbours[0]):.1f}")
183
+ talk(f"exp(0.3 R) first row {[round(float(v), 4) for v in graph_kernel[0]]} detailed balance "
184
+ f"gap {float((flux - flux.T).abs().max()):.2e}")
185
+
186
+ # Train the denoiser against the discrete-time bound.
187
+ alpha_bar, beta = schedule(args.timesteps)
188
+ matrices = (torch.stack([one_step(float(b)) for b in beta]).to(device),
189
+ torch.stack([cumulative(float(a)) for a in alpha_bar]).to(device),
190
+ alpha_bar.to(device))
191
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
192
+ train_tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
193
+ valid_tokens = splits["valid_tokens"].to(device) # [M, L] held out for the bound below.
194
+ model = SequenceDenoiser(DNA_SIZE, args.length, width=args.width).to(device)
195
+ talk(f"device={device} parameters={count_parameters(model):,} T={args.timesteps}")
196
+
197
+ def step_loss(step: int) -> torch.Tensor:
198
+ rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device)
199
+ return d3pm_loss(model, train_tokens[rows], matrices) # One clean batch into one scalar.
200
+
201
+ losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
202
+ with torch.no_grad():
203
+ held_out = sum(float(d3pm_loss(model, valid_tokens, matrices)) for _ in range(4)) / 4.0
204
+
205
+ torch.manual_seed(args.seed + 1) # Fix the ancestral draws, so the samples repeat.
206
+ samples = sample(model, args.samples, args.length, matrices) # [B, L] over ACGT.
207
+ report = {
208
+ "alpha_bar after two steps": alpha_bar_two,
209
+ "Q_bar_2 on the diagonal": float(product[0, 0]),
210
+ "Q_bar_2 off the diagonal": float(product[0, 1]),
211
+ "product at T = 1000": limits[1000],
212
+ "q(x_1 = A | C, A)": float(history[0]),
213
+ "q(x_1 = C | C, A)": float(history[1]),
214
+ "gaussian neighbour weight": float(neighbours[1]),
215
+ "gaussian far weight": float(neighbours[0]),
216
+ "exp(0.3 R) at (A, C)": float(graph_kernel[0, 1]),
217
+ "graph detailed balance gap": float((flux - flux.T).abs().max()),
218
+ "training bound, last 50": sum(losses[-50:]) / len(losses[-50:]),
219
+ "validation bound, nats/seq": held_out,
220
+ "GC fraction": gc_fraction(samples),
221
+ "GC fraction in the data": gc_fraction(train_tokens),
222
+ "motif fraction": motif_fraction(samples),
223
+ "motif fraction in the data": motif_fraction(train_tokens),
224
+ "distinct samples": unique_fraction(samples),
225
+ "letter entropy, nats": token_entropy(samples, DNA_SIZE),
226
+ }
227
+ print_report("d3pm: discrete-time diffusion on DNA", report, decode_dna(samples))
228
+ save_run(args.out, model=model, config=vars(args), report=report,
229
+ losses=losses, samples=decode_dna(samples))
230
+
231
+
232
+ # %% 08. Run the script
233
+ if __name__ == "__main__": # Execute when launched directly.
234
+ main() # Work the finite-step numbers, train against the bound, and generate.
lecture_4/guidance.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Guiding masked diffusion on DNA with Doob's h-transform.
2
+
3
+ Conditioning a jump process on an event observed at the end of its path multiplies every rate by
4
+ the ratio of the value function at the destination to the value at the current state. The reverse
5
+ process is itself such a transform, with h_t = p_t/pi, and a second transform by a classifier aims
6
+ generation at a property. Three estimates of that classifier appear here: the exact one, its
7
+ first-order expansion in the one-hot input, and the conditional denoiser of classifier-free
8
+ guidance.
9
+
10
+ Chapter 4, "Conditioning a jump process: Doob's h-transform", "The reverse process as an
11
+ h-transform" and "Guiding generation toward a property". Time runs from t = 0 clean to t = 1 all
12
+ masks, the survival probability is alpha_t = 1 - t, and the rates R_t(x, y) act on probability
13
+ rows, p_dot = p R. Trained on the synthetic DNA of cis6270.data, whose label is a GC fraction of at
14
+ least 0.6. Prints the worked guided jump and the GC fraction under each kind of guidance.
15
+
16
+ python lecture_4/guidance.py # train, then sample four ways
17
+ python lecture_4/guidance.py --gamma 3 # a sharper transform
18
+ python lecture_4/guidance.py --steps 20 # a few seconds, for a first look
19
+ """
20
+
21
+ # %% 01. Imports and configuration
22
+ import torch # Tensors, gradients and the categorical draws.
23
+ import torch.nn as nn # The classifier's layers.
24
+ import torch.nn.functional as F # Cross-entropy and the one-hot encoding.
25
+
26
+ from cis6270.data import DNA_SIZE, decode_dna, dna_splits # ACGT, K = 4 and the shared sequences.
27
+ from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
28
+ from cis6270.nets import TimeEmbedding, SequenceDenoiser, count_parameters
29
+ from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
30
+
31
+ MASK = DNA_SIZE # A, C, G, T are 0 to 3, and token 4 is the mask m.
32
+ VOCAB = DNA_SIZE + 1 # The masked alphabet V_m has K + 1 = 5 symbols.
33
+ NULL = 2 # The null label the conditional denoiser drops to, one past the two real labels.
34
+ DROP = 0.1 # How often training replaces the label by the null one.
35
+ TIME_FLOOR = 1e-3 # The reveal rate w(t) = 1/t is unbounded at t = 0.
36
+
37
+
38
+ # %% 02. Doob's h-transform
39
+ #
40
+ # R^h_t(x, y) = R_t(x, y) h_t(y)/h_t(x). A jump that raises the chance of the event is faster by
41
+ # the factor by which it raises it, the graph of allowed jumps is unchanged, and a constant h
42
+ # changes nothing, which is the statement that conditioning on a certain event does nothing.
43
+ def h_transform(R: torch.Tensor, h: torch.Tensor) -> torch.Tensor:
44
+ """Reweight every off-diagonal rate by h(y)/h(x), then refill the diagonal. Returns [K, K]."""
45
+ transformed = R * (h[None, :] / h[:, None]) # [K, K] one ratio per edge.
46
+ transformed.fill_diagonal_(0.0) # The diagonal is fixed by the row-sum rule.
47
+ return transformed - torch.diag(transformed.sum(dim=1)) # [K, K] a rate matrix again.
48
+
49
+
50
+ # %% 03. The reverse process as an h-transform
51
+ #
52
+ # For a forward process in detailed balance with pi, the reverse rate equals the forward rate times
53
+ # h_t(x)/h_t(y) with h_t = p_t/pi, so generation is the noise process aimed at the states that are
54
+ # more likely under the data than under pure noise.
55
+ def value_from_marginal(p: torch.Tensor, pi: torch.Tensor) -> torch.Tensor:
56
+ """h_t = p_t / pi, defined wherever pi is positive, as uniform noise is and masking is not."""
57
+ return p / pi # [K] the excess of the data marginal over the noise distribution.
58
+
59
+
60
+ # %% 04. A classifier on noisy sequences
61
+ #
62
+ # The value function for a property is h_t(x) = p(c | X_t = x), estimated by a classifier trained at
63
+ # every noise level. The input is a one-hot sequence, so a gradient with respect to it exists and
64
+ # Section 06 can take one.
65
+ class NoisyClassifier(nn.Module):
66
+ """A small network from a one-hot noisy sequence and a time to the logit of p(c | z, t)."""
67
+
68
+ def __init__(self, length: int, width: int = 128):
69
+ super().__init__()
70
+ self.embed = nn.Linear(VOCAB, width, bias=False) # One-hot in, so the input has a gradient.
71
+ self.position = nn.Embedding(length, width) # Where each token sits.
72
+ self.time = TimeEmbedding(width) # How far along the corruption we are.
73
+ self.body = nn.Sequential(nn.Linear(width, width), nn.SiLU(), nn.Linear(width, width),
74
+ nn.SiLU())
75
+ self.head = nn.Linear(width, 1) # One logit per sequence.
76
+
77
+ def forward(self, one_hot: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
78
+ """one_hot: [B, L, V], t: [B] -> [B] the logit of the property."""
79
+ positions = torch.arange(one_hot.shape[1], device=one_hot.device) # [L].
80
+ h = (self.embed(one_hot) + self.position(positions)[None]
81
+ + self.time(t)[:, None, :]) # [B, L, width].
82
+ return self.head(self.body(h).mean(dim=1))[:, 0] # [B] pooled over the positions.
83
+
84
+
85
+ def as_one_hot(z: torch.Tensor) -> torch.Tensor:
86
+ """The sequence over the masked alphabet as floats, so a gradient in the input exists."""
87
+ return F.one_hot(z, VOCAB).to(torch.get_default_dtype()) # [B, L, V] one row per position.
88
+
89
+
90
+ def classifier_loss(classifier: nn.Module, x0: torch.Tensor, labels: torch.Tensor) -> torch.Tensor:
91
+ """Binary cross-entropy on sequences corrupted to a random noise level."""
92
+ t = torch.rand(x0.shape[0], device=x0.device).clamp(min=TIME_FLOOR) # [B] one level each.
93
+ keep = torch.rand(x0.shape, device=x0.device) < (1.0 - t)[:, None] # [B, L] the survival draw.
94
+ z = torch.where(keep, x0, MASK) # [B, L] a partially masked sequence.
95
+ logit = classifier(as_one_hot(z), t) # [B] the predicted log odds of the property.
96
+ return F.binary_cross_entropy_with_logits(logit, labels.to(torch.get_default_dtype()))
97
+
98
+
99
+ # %% 05. Exact classifier guidance
100
+ #
101
+ # The guided rate has p(c | z^{i <- a}) in it at every position and every token, so the exact
102
+ # version costs one classifier evaluation per candidate. A length-16 DNA sequence has 64 of them.
103
+ def exact_ratio(classifier: nn.Module, z: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
104
+ """p(c | z^{i <- a}) / p(c | z) at every position and clean token. Returns [B, L, K]."""
105
+ batch, length = z.shape
106
+ one_hot = as_one_hot(z) # [B, L, V] the current state.
107
+ here = torch.sigmoid(classifier(one_hot, t))[:, None, None] # [B, 1, 1] the value at z.
108
+ candidates = one_hot[:, None, None].repeat(1, length, DNA_SIZE, 1, 1) # [B, L, K, L, V].
109
+ index = torch.arange(length, device=z.device) # [L] the position being changed.
110
+ candidates[:, index, :, index] = torch.eye(VOCAB, device=z.device)[:DNA_SIZE] # Set position i.
111
+ flat = candidates.reshape(batch * length * DNA_SIZE, length, VOCAB) # One batch of candidates.
112
+ time = t[:, None, None].expand(batch, length, DNA_SIZE).reshape(-1) # [B L K] the same time.
113
+ value = torch.sigmoid(classifier(flat, time)).view(batch, length, DNA_SIZE) # [B, L, K].
114
+ return value / here.clamp(min=1e-8) # [B, L, K] the h-transform factor on each jump.
115
+
116
+
117
+ # %% 06. The gradient approximation
118
+ #
119
+ # One backward pass through the classifier gives every ratio at once, because a first-order
120
+ # expansion of log p(c | .) in the one-hot input differs between two candidates only in the two
121
+ # coordinates that change.
122
+ def gradient_ratio(classifier: nn.Module, z: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
123
+ """exp(g_{i,a} - g_{i,z^i}) with g the gradient of log p(c | z) in the one-hot. [B, L, K]."""
124
+ one_hot = as_one_hot(z).requires_grad_(True) # [B, L, V] the point of the expansion.
125
+ with torch.enable_grad(): # The sampler runs under no_grad, and this one pass needs gradients.
126
+ log_value = F.logsigmoid(classifier(one_hot, t)).sum() # One backward covers the batch.
127
+ gradient, = torch.autograd.grad(log_value, one_hot) # [B, L, V] the expansion's slopes.
128
+ current = gradient.gather(2, z[:, :, None]) # [B, L, 1] the coordinate that is being left.
129
+ return (gradient[..., :DNA_SIZE] - current).exp() # [B, L, K] the approximated ratio.
130
+
131
+
132
+ # %% 07. The guided sampler
133
+ #
134
+ # The reveal rate at a masked position is w(t) d_theta(a), so the h-transform multiplies each of
135
+ # those rates by its own ratio. Both the probability of revealing anything and the letter that
136
+ # appears change, and a ratio of one at every token returns the unguided sampler exactly.
137
+ def denoiser_probabilities(model: nn.Module, z: torch.Tensor, time: torch.Tensor,
138
+ labels: torch.Tensor | None) -> torch.Tensor:
139
+ """d_theta(. | z, t) over the four bases, with the zero-mask rule applied. Returns [B, L, K]."""
140
+ logits = model(z, time, labels) # [B, L, 5] the clean-token prediction.
141
+ logits[..., MASK] = -1e9 # The zero-mask rule: no posterior mass belongs on m.
142
+ return logits.softmax(-1)[..., :DNA_SIZE] # [B, L, K], which sums to one over ACGT.
143
+
144
+
145
+ def guided_weights(model: nn.Module, z: torch.Tensor, time: torch.Tensor,
146
+ labels: torch.Tensor | None, ratio_fn, gamma: float) -> torch.Tensor:
147
+ """The reveal rates over w(t): the denoiser times its h-transform ratio. Returns [B, L, K]."""
148
+ weights = denoiser_probabilities(model, z, time, labels) # [B, L, K] the unguided rates.
149
+ if ratio_fn is None:
150
+ return weights # [B, L, K] summing to one, which is the unguided exit rate w(t).
151
+ return weights * ratio_fn(z, time).clamp(min=1e-8) ** gamma # [B, L, K] the h-transform.
152
+
153
+
154
+ @torch.no_grad() # Only the gradient ratio reopens the graph, and it does so locally.
155
+ def sample(model: nn.Module, labels: torch.Tensor | None, batch: int, length: int,
156
+ steps: int = 32, ratio_fn=None, gamma: float = 1.0) -> torch.Tensor:
157
+ """Generate from all masks, with the reveal rates reweighted by ratio_fn. Returns [B, L]."""
158
+ device = next(model.parameters()).device
159
+ z = torch.full((batch, length), MASK, device=device) # [B, L] the all-mask start.
160
+ ts = torch.linspace(1.0, TIME_FLOOR, steps + 1, device=device) # The reverse grid.
161
+ for t, s in zip(ts[:-1], ts[1:]):
162
+ time = t.expand(batch) # [B] the shared time of this step.
163
+ weights = guided_weights(model, z, time, labels, ratio_fn, gamma) # [B, L, K].
164
+ total = weights.sum(-1, keepdim=True) # [B, L, 1] the transformed exit rate over w(t).
165
+ reveal = ((t - s) / t * total).clamp(max=1.0) # [B, L, 1] the probability of acting at all.
166
+ drawn = torch.distributions.Categorical(probs=weights / total).sample() # [B, L].
167
+ open_now = torch.rand(z.shape, device=device)[:, :, None] < reveal # [B, L, 1].
168
+ z = torch.where((z == MASK) & open_now[..., 0], drawn, z) # Carry-over unmasking.
169
+ # The last step runs down to s = 0, where the schedule reveals every remaining mask. The same
170
+ # transform picks the letter, so the guidance reaches the positions that waited to the end.
171
+ last = ts[-1].expand(batch) # [B] the final time of the grid.
172
+ weights = guided_weights(model, z, last, labels, ratio_fn, gamma) # [B, L, K].
173
+ drawn = torch.distributions.Categorical( # [B, L] one letter per position.
174
+ probs=weights / weights.sum(-1, keepdim=True)).sample()
175
+ return torch.where(z == MASK, drawn, z) # [B, L] clean DNA.
176
+
177
+
178
+ # %% 08. The report
179
+ def main() -> None:
180
+ parser = common_parser(__doc__.split("\n")[0])
181
+ parser.add_argument("--gamma", type=float, default=2.0, help="exponent on the h ratio")
182
+ # Two networks train and four samplers run, so each network gets 1000 steps and each sampler
183
+ # measures 64 sequences, which is the longest default run of the lecture.
184
+ parser.set_defaults(steps=1000, sample_steps=32, samples=64)
185
+ args = parser.parse_args()
186
+ set_seed(args.seed)
187
+ device = pick_device(args.device)
188
+ talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
189
+
190
+ # Sections 02 and 03, the reverse rates read off h_t = p_t / pi at alpha_t = 1/2.
191
+ marginal = torch.tensor([0.5, 0.25, 0.125, 0.125]) # p_t over (A, C, G, T).
192
+ pi = torch.full((DNA_SIZE,), 0.25) # Uniform noise.
193
+ unit = torch.ones(DNA_SIZE, DNA_SIZE) - torch.diag(torch.full((DNA_SIZE,), DNA_SIZE * 1.0))
194
+ values = value_from_marginal(marginal, pi) # [4] h_t = (2, 1, 0.5, 0.5).
195
+ reverse = h_transform(unit, values) # [4, 4] the reverse rates, at unit forward rates.
196
+ conditioned = value_from_marginal(torch.tensor([0.625, 0.125, 0.125, 0.125]), pi) # Clean A.
197
+ talk(f"h from the marginal {[round(float(v), 2) for v in values]} reverse row for C "
198
+ f"{[round(float(v), 2) for v in reverse[1]]}")
199
+ talk(f"h given a clean A {[round(float(v), 2) for v in conditioned]} rate C to A "
200
+ f"{float(conditioned[0] / conditioned[1]):.0f} given a clean C "
201
+ f"{float(conditioned[1] / conditioned[0]):.1f}")
202
+
203
+ # Section 05 on paper, the worked guided jump out of C with a classifier on the four bases.
204
+ unguided = torch.tensor([2.0, 0.5, 0.5]) # The reverse rates toward A, G and T.
205
+ classifier_value = torch.tensor([0.1, 0.6, 0.2]) / 0.2 # p(c | destination) over p(c | C).
206
+ guided = unguided * classifier_value # [3] the guided rates.
207
+ sharper = unguided * classifier_value ** 2 # [3] the same transform at gamma = 2.
208
+ shares = [round(float(v), 4) for v in guided / guided.sum()] # Where the jump most likely goes.
209
+ talk(f"guided jump {[round(float(v), 2) for v in guided]} exit "
210
+ f"{float(guided.sum()):.0f} destinations {shares}")
211
+ talk(f"at gamma = 2 {[round(float(v), 2) for v in sharper]} exit "
212
+ f"{float(sharper.sum()):.1f} destinations "
213
+ f"{[round(float(v), 4) for v in sharper / sharper.sum()]} holding "
214
+ f"{1 / float(sharper.sum()):.4f}")
215
+
216
+ # Train the conditional denoiser and the classifier on the shared synthetic DNA.
217
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
218
+ tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
219
+ labels = splits["train_labels"].to(device) # [N] one when the GC fraction is at least 0.6.
220
+ model = SequenceDenoiser(VOCAB, args.length, width=args.width, num_classes=2).to(device)
221
+ classifier = NoisyClassifier(args.length, args.width).to(device)
222
+ share = float(labels.to(torch.get_default_dtype()).mean()) # How much of the data is GC-rich.
223
+ talk(f"device={device} denoiser={count_parameters(model):,} "
224
+ f"classifier={count_parameters(classifier):,} labelled {share:.2f}")
225
+
226
+ def denoiser_loss(step: int) -> torch.Tensor:
227
+ """The MDLM loss, with the label replaced by the null one a tenth of the time."""
228
+ rows = torch.randint(0, len(tokens), (args.batch_size,), device=device)
229
+ x0, y = tokens[rows], labels[rows].clone()
230
+ y[torch.rand(len(y), device=device) < DROP] = NULL # Dropping the label trains both heads.
231
+ t = torch.rand(len(x0), device=device).clamp(min=TIME_FLOOR) # [B] one noise level each.
232
+ zt = torch.where(torch.rand(x0.shape, device=device) < (1.0 - t)[:, None], x0, MASK)
233
+ logits = model(zt, t, y) # [B, L, 5].
234
+ logits[..., MASK] = -1e9 # The zero-mask rule.
235
+ ce = F.cross_entropy(logits.transpose(1, 2), x0, reduction="none") # [B, L].
236
+ return ((1.0 / t)[:, None] * ce * (zt == MASK)).sum(-1).mean()
237
+
238
+ def labelled_batch(step: int) -> torch.Tensor:
239
+ """One batch of clean sequences and their labels, for the classifier."""
240
+ rows = torch.randint(0, len(tokens), (args.batch_size,), device=device)
241
+ return classifier_loss(classifier, tokens[rows], labels[rows])
242
+
243
+ losses = train(model, denoiser_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
244
+ classifier_losses = train(classifier, labelled_batch, steps=args.steps, lr=args.lr,
245
+ quiet=args.quiet)
246
+
247
+ # Section 06, the exact ratios against their first-order approximation on one noisy batch.
248
+ probe_t = torch.full((32,), 0.5, device=device) # [32] halfway through the corruption.
249
+ probe = torch.where(torch.rand(tokens[:32].shape, device=device) < 0.5, tokens[:32], MASK)
250
+ with torch.no_grad():
251
+ exact = exact_ratio(classifier, probe, probe_t) # [32, L, K] every candidate evaluated.
252
+ approximate = gradient_ratio(classifier, probe, probe_t) # [32, L, K] one backward pass.
253
+ gap = exact.clamp(min=1e-6).log() - approximate.clamp(min=1e-6).log() # [32, L, K] log ratios.
254
+ agreement = float(gap.abs().mean()) # How far the expansion sits from the exact ratio.
255
+
256
+ # Sample four ways, from the same weights and the same starting state.
257
+ wanted = torch.ones(args.samples, dtype=torch.long, device=device) # Ask for the GC-rich label.
258
+ null = torch.full_like(wanted, NULL) # The label the unconditional head reads.
259
+
260
+ def free_ratio(z: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
261
+ """d_theta(a | z, c) / d_theta(a | z), which is the classifier ratio implied by Bayes."""
262
+ # Both denoisers go through the zero-mask rule first. The loss overwrites the mask logit,
263
+ # so it never trains, and a softmax that kept it would leave a stray factor in the ratio.
264
+ conditional = denoiser_probabilities(model, z, t, wanted) # [B, L, K] with the label.
265
+ unconditional = denoiser_probabilities(model, z, t, null) # [B, L, K] with the null label.
266
+ return conditional / unconditional.clamp(min=1e-8) # [B, L, K] the h-transform factor.
267
+
268
+ runs = {}
269
+ for name, ratio in (
270
+ ("unguided", None),
271
+ ("exact classifier", lambda z, t: exact_ratio(classifier, z, t)),
272
+ ("gradient approximation", lambda z, t: gradient_ratio(classifier, z, t)),
273
+ ("classifier free", free_ratio),
274
+ ):
275
+ torch.manual_seed(args.seed + 1) # The same reveal draws for every run.
276
+ runs[name] = sample(model, null, args.samples, args.length, args.sample_steps,
277
+ ratio, args.gamma)
278
+
279
+ report = {
280
+ "h at A from the marginal": float(values[0]),
281
+ "reverse rate C to A": float(reverse[1, 0]),
282
+ "guided rate C to A": float(guided[0]),
283
+ "guided rate C to G": float(guided[1]),
284
+ "guided exit rate": float(guided.sum()),
285
+ "guided exit rate at gamma = 2": float(sharper.sum()),
286
+ "share on G at gamma = 2": float((sharper / sharper.sum())[1]),
287
+ "denoiser loss, last 50": sum(losses[-50:]) / len(losses[-50:]),
288
+ "classifier loss, last 50": sum(classifier_losses[-50:]) / len(classifier_losses[-50:]),
289
+ "mean log-ratio gap, exact vs gradient": agreement,
290
+ "GC fraction in the data": gc_fraction(tokens),
291
+ "motif fraction in the data": motif_fraction(tokens),
292
+ "gamma": args.gamma,
293
+ }
294
+ for name, drawn in runs.items():
295
+ report[f"GC fraction, {name}"] = gc_fraction(drawn)
296
+ # The label is a GC threshold, so the motif is the structure guidance leaves alone.
297
+ report[f"motif fraction, {name}"] = motif_fraction(drawn)
298
+ report[f"distinct, {name}"] = unique_fraction(drawn)
299
+ report["letter entropy, exact classifier"] = token_entropy(runs["exact classifier"], DNA_SIZE)
300
+ print_report("guidance: h-transforms of masked diffusion", report,
301
+ decode_dna(runs["exact classifier"]))
302
+ save_run(args.out, model=model, config=vars(args), report=report, losses=losses,
303
+ samples=decode_dna(runs["exact classifier"]))
304
+
305
+
306
+ # %% 09. Run the script
307
+ if __name__ == "__main__": # Execute when launched directly.
308
+ main() # Work the guided jump, train the two networks, and sample four ways.
lecture_4/mdlm.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Masked diffusion on DNA, the whole model in thirty lines.
2
+
3
+ Corruption replaces each base by the mask with probability 1 - alpha_t, a network reads the masked
4
+ sequence and predicts the clean token at every position, and generation reveals masked positions
5
+ with the probability the schedule allows. The corruption, the loss and the sampler below are the
6
+ three listings of Section "The whole model for DNA in thirty lines", expanded with comments.
7
+
8
+ Chapter 4, "Masked diffusion on one token" through "The whole model for DNA in thirty lines". Time
9
+ runs from t = 0 clean to t = 1 all masks, the survival probability is alpha_t = 1 - t, and the rates
10
+ R_t(x, y) act on probability rows, p_dot = p R. Trained on the synthetic DNA of cis6270.data. Prints
11
+ the chapter's worked reverse step and loss term, then the training loss and the sample statistics.
12
+
13
+ python lecture_4/mdlm.py # train and generate, about a minute on a CPU
14
+ python lecture_4/mdlm.py --steps 20 # a few seconds, for a first look
15
+ """
16
+
17
+ # %% 01. Imports and configuration
18
+ import math # The worked numbers of the chapter are logarithms.
19
+
20
+ import torch # Tensors, gradients and the categorical draws.
21
+ import torch.nn.functional as F # Cross-entropy at every position.
22
+ from torch.distributions import Categorical # One draw per position from the denoiser.
23
+
24
+ from cis6270.data import DNA_SIZE, decode_dna, dna_splits # ACGT, K = 4 and the shared sequences.
25
+ from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
26
+ from cis6270.nets import SequenceDenoiser, count_parameters # The denoiser d_theta(. | z, t).
27
+ from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
28
+
29
+ MASK = DNA_SIZE # A, C, G, T are 0 to 3, and token 4 is the mask m.
30
+ VOCAB = DNA_SIZE + 1 # The masked alphabet V_m has K + 1 = 5 symbols.
31
+ TIME_FLOOR = 1e-3 # The weight 1/t is unbounded at t = 0, so draws start just above it.
32
+
33
+
34
+ # %% 02. The corruption
35
+ #
36
+ # Fact 2 of the construction: the forward marginal q_t(z^i | x_0^i) = alpha_t delta(z^i, x_0^i) +
37
+ # (1 - alpha_t) delta(z^i, m) is closed form, so any noise level is reachable in one draw.
38
+ def corrupt(x0: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
39
+ """Mask each position independently with probability 1 - alpha_t. x0: [B, L], t: [B, 1]."""
40
+ alpha = 1.0 - t # The linear schedule, alpha_0 = 1 and alpha_1 = 0.
41
+ keep = torch.rand(x0.shape, device=x0.device) < alpha # [B, L] one Bernoulli survival draw.
42
+ return torch.where(keep, x0, MASK) # [B, L] the clean token where it survived, m where it did not.
43
+
44
+
45
+ # %% 03. The weighted cross-entropy at masked positions
46
+ #
47
+ # The per-step KL divergence collapses to -r log d_theta(a), and summing the steps into an integral
48
+ # replaces r by the reveal rate w(t) = -alpha_dot / (1 - alpha) = 1/t on the linear schedule.
49
+ def loss(model: torch.nn.Module, x0: torch.Tensor) -> torch.Tensor:
50
+ """One Monte Carlo sample of L_mask, with one time and one corruption per sequence."""
51
+ t = torch.rand(x0.shape[0], device=x0.device).clamp(min=TIME_FLOOR) # [B] one noise level each.
52
+ zt = corrupt(x0, t[:, None]) # [B, L] the time spreads across the positions of its own sequence.
53
+ logits = model(zt, t) # [B, L, 5] the denoiser reads the whole noisy sequence.
54
+ logits[..., MASK] = -1e9 # The zero-mask rule: no posterior mass belongs on m.
55
+ ce = F.cross_entropy(logits.transpose(1, 2), x0, reduction="none") # [B, L] -log d_theta(x_0^i).
56
+ w = 1.0 / t # [B] the reveal rate, which is -alpha_dot / (1 - alpha) on this schedule.
57
+ return (w[:, None] * ce * (zt == MASK)).sum(-1).mean() # Visible positions contribute zero.
58
+
59
+
60
+ # %% 04. The sampler
61
+ #
62
+ # A masked position is revealed with probability r_{s,t} = (alpha_s - alpha_t)/(1 - alpha_t), which
63
+ # is (t - s)/t here, and the letter that appears is one draw from the denoiser.
64
+ @torch.no_grad() # Generation needs no gradient tracking.
65
+ def sample(model: torch.nn.Module, batch: int, length: int, steps: int = 32) -> torch.Tensor:
66
+ """Generate from all masks down to clean DNA. Returns [B, L] token ids over ACGT."""
67
+ device = next(model.parameters()).device # Sample on the device the weights sit on.
68
+ z = torch.full((batch, length), MASK, device=device) # [B, L] the all-mask start, pi = delta_m.
69
+ ts = torch.linspace(1.0, TIME_FLOOR, steps + 1, device=device) # The reverse grid, t down to 0.
70
+ for t, s in zip(ts[:-1], ts[1:]): # One interval [s, t] at a time.
71
+ logits = model(z, t.expand(batch)) # [B, L, 5] the clean-token prediction at this level.
72
+ logits[..., MASK] = -1e9 # The zero-mask rule again, at sampling time.
73
+ x0 = Categorical(logits=logits).sample() # [B, L] one candidate clean token per position.
74
+ unmask = torch.rand(z.shape, device=device) < (t - s) / t # [B, L] the reveal draw.
75
+ z = torch.where((z == MASK) & unmask, x0, z) # Carry-over unmasking: a revealed base stays.
76
+ logits = model(z, ts[-1].expand(batch)) # The last step runs down to s = 0, where r = 1.
77
+ logits[..., MASK] = -1e9 # The same rule once more.
78
+ return torch.where(z == MASK, Categorical(logits=logits).sample(), z) # [B, L] clean DNA.
79
+
80
+
81
+ # %% 05. A complete reverse step, worked
82
+ #
83
+ # With alpha_s = 0.7 and alpha_t = 0.4, which the linear schedule reaches at s = 0.3 and t = 0.6,
84
+ # the reveal probability is half and the denoiser splits that half between A and C.
85
+ def reveal_probability(alpha_s: float, alpha_t: float) -> float:
86
+ """r_{s,t} = (alpha_s - alpha_t)/(1 - alpha_t), the lost survival mass over the mask mass."""
87
+ return (alpha_s - alpha_t) / (1.0 - alpha_t) # A function of the schedule and of nothing else.
88
+
89
+
90
+ def reverse_step_distribution(alpha_s: float, alpha_t: float, denoiser: list[float]) -> list[float]:
91
+ """The reverse law over (A, C, G, T, m): r times the denoiser, with 1 - r left on the mask."""
92
+ r = reveal_probability(alpha_s, alpha_t) # The probability of acting at all.
93
+ return [r * d for d in denoiser] + [1.0 - r] # [5] the five outcomes of one step.
94
+
95
+
96
+ # %% 06. One term of the loss, and the rate error it comes from
97
+ #
98
+ # At t = 0.5 the weight is w(0.5) = 2, and the clean sequence ACGT corrupted to AmGm contributes the
99
+ # negative log probabilities of C and T. The chapter reaches the same weighted cross-entropy a
100
+ # second time from the rate error, on the reveal figure's denoiser: summing l(A, B) over every
101
+ # destination at one masked position leaves w(t) times the negative log probability of the clean
102
+ # token there, once the staying terms restore the normalization of the denoiser.
103
+ def reveal_rate(t: float) -> float:
104
+ """w(t) = -alpha_dot / (1 - alpha_t), which is 1/t for the linear schedule."""
105
+ return 1.0 / t # The rate at which a masked position reveals something, whatever the denoiser.
106
+
107
+
108
+ def rate_error(target: float, model_rate: float) -> float:
109
+ """l(A, B) = A log(A/B) - A + B, the Bregman divergence that the path KL divergence produces."""
110
+ if target == 0.0:
111
+ return model_rate # l(0, B) = B, which is the staying term on its own.
112
+ return target * math.log(target / model_rate) - target + model_rate # Zero exactly at B = A.
113
+
114
+
115
+ # %% 07. The report
116
+ def main() -> None:
117
+ parser = common_parser(__doc__.split("\n")[0])
118
+ # The motif needs a longer budget than the GC fraction, so the default trains for 1000 steps
119
+ # and measures on 64 sequences. The whole run stays inside about a minute on one core.
120
+ parser.set_defaults(steps=1000, sample_steps=64, samples=64)
121
+ args = parser.parse_args()
122
+ set_seed(args.seed) # The same seed gives the same training batches and the same samples.
123
+ device = pick_device(args.device)
124
+ talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
125
+
126
+ # Section 05, the worked reverse step at alpha_s = 0.7 and alpha_t = 0.4.
127
+ r = reveal_probability(0.7, 0.4) # 0.3 of recovered mass over 0.6 of mask mass.
128
+ step_law = reverse_step_distribution(0.7, 0.4, [0.6, 0.4, 0.0, 0.0]) # [5] over (A, C, G, T, m).
129
+ talk(f"reverse step r = {r:.1f} p(A, C, G, T, m) = {[round(v, 2) for v in step_law]} "
130
+ f"sum {sum(step_law):.1f}")
131
+
132
+ # Section 06, one term of the loss at t = 0.5 on ACGT corrupted to AmGm.
133
+ weight = reveal_rate(0.5) # w(0.5) = 2.
134
+ masked_ce = -math.log(0.7) - math.log(0.5) # The clean tokens C and T at positions 2 and 4.
135
+ worked_term = weight * masked_ce # One sample of the objective.
136
+ position_two = [0.2, 0.6, 0.1, 0.1] # The reveal figure's denoiser at the second position.
137
+ reveal_rates = [weight * d for d in position_two] # [4] rates that sum to w(t) = 2.
138
+ collapse = rate_error(weight, reveal_rates[1]) + sum( # The clean token is C here.
139
+ rate_error(0.0, rate) for i, rate in enumerate(reveal_rates) if i != 1)
140
+ talk(f"loss term at t=0.5 w = {weight:.0f} masked CE = {masked_ce:.5f} "
141
+ f"w CE = {worked_term:.5f}")
142
+ talk(f"reveal rates {[round(v, 1) for v in reveal_rates]} sum {sum(reveal_rates):.0f} "
143
+ f"rate error {collapse:.4f} = w(-log 0.6) = {weight * -math.log(0.6):.4f}")
144
+
145
+ # Train the denoiser on the shared synthetic DNA.
146
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
147
+ train_tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
148
+ valid_tokens = splits["valid_tokens"].to(device) # [M, L] held out for the bound below.
149
+ model = SequenceDenoiser(VOCAB, args.length, width=args.width).to(device)
150
+ talk(f"device={device} parameters={count_parameters(model):,} "
151
+ f"train={len(train_tokens)} length={args.length}")
152
+
153
+ def step_loss(step: int) -> torch.Tensor:
154
+ """One optimizer step draws its own batch of clean sequences."""
155
+ rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device)
156
+ return loss(model, train_tokens[rows]) # [B, L] clean sequences into one scalar.
157
+
158
+ losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
159
+
160
+ # The validation bound, averaged over several corruptions of the held-out sequences.
161
+ with torch.no_grad():
162
+ bound = sum(float(loss(model, valid_tokens)) for _ in range(4)) / 4.0 # Nats per sequence.
163
+
164
+ # Generate, then measure the structure the data was built with.
165
+ torch.manual_seed(args.seed + 1) # Fix the reveal draws, so the samples repeat.
166
+ samples = sample(model, args.samples, args.length, args.sample_steps) # [B, L] over ACGT.
167
+ report = {
168
+ "reveal probability r": r,
169
+ "reverse step on the mask": step_law[-1],
170
+ "weight w(0.5)": weight,
171
+ "worked loss term": worked_term,
172
+ "rate error at position 2": collapse,
173
+ "training loss, last 50": sum(losses[-50:]) / len(losses[-50:]),
174
+ "validation bound, nats/seq": bound,
175
+ "bound, nats per token": bound / args.length,
176
+ "GC fraction": gc_fraction(samples),
177
+ "GC fraction in the data": gc_fraction(train_tokens),
178
+ "motif fraction": motif_fraction(samples),
179
+ "motif fraction in the data": motif_fraction(train_tokens),
180
+ "distinct samples": unique_fraction(samples),
181
+ "letter entropy, nats": token_entropy(samples, DNA_SIZE),
182
+ }
183
+ print_report("mdlm: masked diffusion on DNA", report, decode_dna(samples))
184
+ save_run(args.out, model=model, config=vars(args), report=report,
185
+ losses=losses, samples=decode_dna(samples))
186
+
187
+
188
+ # %% 08. Run the script
189
+ if __name__ == "__main__": # Execute when launched directly.
190
+ main() # Work the numbers, train the denoiser, and generate.
lecture_4/planner.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Choosing the order of the reveals in masked diffusion on DNA.
2
+
3
+ Every masked position leaves at the same total rate w(t), so which positions a step reveals is free
4
+ and the marginal reverse rates stay where they were. A planner spends that freedom on the positions
5
+ the denoiser is most sure about, a corrector returns the least supported visible tokens to the
6
+ mask, and the loss can be reweighted toward the positions the sampler reaches first.
7
+
8
+ Chapter 4, "Choosing the order of the reveals", "One sampling step with a planner" and "Bringing
9
+ the planner into training". Time runs from t = 0 clean to t = 1 all masks, the survival probability
10
+ is alpha_t = 1 - t, and the rates R_t(x, y) act on probability rows, p_dot = p R. Trained on the
11
+ synthetic DNA of cis6270.data. Prints the worked selection, the worked PAPL weights, and the
12
+ sample statistics under both samplers.
13
+
14
+ python lecture_4/planner.py # train with planner weights, sample both ways
15
+ python lecture_4/planner.py --remask 0 # confidence order with no corrector
16
+ python lecture_4/planner.py --steps 20 # a few seconds, for a first look
17
+ """
18
+
19
+ # %% 01. Imports and configuration
20
+ import torch # Tensors, gradients and the categorical draws.
21
+ import torch.nn.functional as F # Cross-entropy at every position.
22
+
23
+ from cis6270.data import DNA_SIZE, decode_dna, dna_splits # ACGT, K = 4 and the shared sequences.
24
+ from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
25
+ from cis6270.nets import SequenceDenoiser, count_parameters # The denoiser d_theta(. | z, t).
26
+ from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
27
+
28
+ MASK = DNA_SIZE # A, C, G, T are 0 to 3, and token 4 is the mask m.
29
+ VOCAB = DNA_SIZE + 1 # The masked alphabet V_m has K + 1 = 5 symbols.
30
+ TIME_FLOOR = 1e-3 # The reveal rate w(t) = 1/t is unbounded at t = 0.
31
+
32
+
33
+ # %% 02. The planner score and the selection it drives
34
+ #
35
+ # The score condenses the distribution at a position into one number. The probability of the most
36
+ # likely clean token is high where the denoiser has committed and low where it is spread out, and
37
+ # the negative entropy of the same distribution is the other natural choice.
38
+ def planner_score(probabilities: torch.Tensor, kind: str = "confidence") -> torch.Tensor:
39
+ """One number per position from d_theta(. | z, t). probabilities: [B, L, K] -> [B, L]."""
40
+ if kind == "entropy":
41
+ return (probabilities * probabilities.clamp(min=1e-20).log()).sum(-1) # Negative entropy.
42
+ return probabilities.max(-1).values # The probability of the most likely clean token.
43
+
44
+
45
+ def topk_where(score: torch.Tensor, eligible: torch.Tensor, count: int) -> torch.Tensor:
46
+ """A boolean mask selecting the ``count`` highest-scoring eligible positions in each row."""
47
+ picked = torch.zeros_like(eligible) # [B, L] nothing selected yet.
48
+ if count <= 0:
49
+ return picked # Taking none is a valid choice, and it recovers the plain sampler.
50
+ ranked = score.masked_fill(~eligible, float("-inf")) # Ineligible positions never win.
51
+ index = ranked.topk(min(count, score.shape[1]), dim=1).indices # [B, count] the best positions.
52
+ return picked.scatter_(1, index, True) & eligible # Rows with too few eligible slots take fewer.
53
+
54
+
55
+ # %% 03. One sampling step with a planner
56
+ #
57
+ # The first four lines are the sampler of the implementation section: one forward pass, the
58
+ # zero-mask rule, a softmax, and one categorical draw per position. Nothing in them depends on the
59
+ # planner, so a trained checkpoint can be sampled this way with no retraining.
60
+ def planner_step(model: torch.nn.Module, z: torch.Tensor, t: torch.Tensor,
61
+ n_unmask: int, n_remask: int, kind: str = "confidence") -> torch.Tensor:
62
+ """One reverse step that reveals the best ``n_unmask`` masks and remasks the worst tokens."""
63
+ logits = model(z, t) # [B, L, 5] the clean-token prediction at this noise level.
64
+ logits[..., MASK] = -1e9 # The zero-mask rule: no posterior mass belongs on m.
65
+ p = logits.softmax(-1) # [B, L, 5] the denoiser as probabilities.
66
+ x0 = torch.distributions.Categorical(probs=p).sample() # [B, L] one candidate per position.
67
+
68
+ score = planner_score(p[..., :DNA_SIZE], kind) # [B, L] how sure the denoiser is here.
69
+ masked = z == MASK # [B, L] the positions a reveal may act on.
70
+ pick = topk_where(score, masked, n_unmask) # [B, L] the best masked positions.
71
+ z = torch.where(pick, x0, z) # Unmask the best.
72
+
73
+ back = topk_where(-score, ~masked, n_remask) # [B, L] the worst visible positions.
74
+ return torch.where(back, torch.full_like(z, MASK), z) # Remask the worst, which is the corrector.
75
+
76
+
77
+ # %% 04. The corrector and its flux balance
78
+ #
79
+ # C_t = R_t + R_hat_t is in detailed balance with p_t, so running it at a fixed time holds the
80
+ # marginal in place. Under masking its forward part returns a visible token to the mask at rate
81
+ # beta(t) and its reverse part reveals a masked position at rate w(t).
82
+ def corrector_fluxes(t: float) -> tuple[float, float]:
83
+ """alpha_t beta(t) and (1 - alpha_t) w(t), the two fluxes across the visible-masked boundary."""
84
+ alpha = 1.0 - t # The linear schedule.
85
+ return alpha * (1.0 / alpha), (1.0 - alpha) * (1.0 / t) # Both equal -alpha_dot, which is one.
86
+
87
+
88
+ # %% 05. Bringing the planner into training
89
+ #
90
+ # The MDLM loss weights every masked position equally, which is right when the order of the reveals
91
+ # is uniform. Once a planner chooses the order, the weight lambda_i = M rho_i puts the gradient on
92
+ # the positions the sampler will reach first, and it averages to one over the masked positions.
93
+ def planner_weights(score: torch.Tensor, masked: torch.Tensor, strength: float) -> torch.Tensor:
94
+ """lambda_i = 1 + strength (M rho_i - 1), with rho the planner's softmax over masked positions."""
95
+ # A large negative number in place of -inf, so a row with nothing masked still has a softmax.
96
+ ranked = score.masked_fill(~masked, -1e4) # [B, L] only masked positions compete.
97
+ rho = ranked.softmax(dim=1) # [B, L] the planner's distribution over which position to fill.
98
+ count = masked.sum(dim=1, keepdim=True).clamp(min=1) # [B, 1] M, the number of masked positions.
99
+ return 1.0 + strength * (count * rho - 1.0) # [B, L] a weight of one at strength zero.
100
+
101
+
102
+ def papl_loss(model: torch.nn.Module, x0: torch.Tensor, strength: float) -> torch.Tensor:
103
+ """The MDLM loss with the planner weight inside the sum over masked positions."""
104
+ t = torch.rand(x0.shape[0], device=x0.device).clamp(min=TIME_FLOOR) # [B] one noise level each.
105
+ keep = torch.rand(x0.shape, device=x0.device) < (1.0 - t)[:, None] # [B, L] the survival draw.
106
+ zt = torch.where(keep, x0, MASK) # [B, L] the corrupted sequence.
107
+ logits = model(zt, t) # [B, L, 5] the clean-token prediction.
108
+ logits[..., MASK] = -1e9 # The zero-mask rule.
109
+ ce = F.cross_entropy(logits.transpose(1, 2), x0, reduction="none") # [B, L].
110
+ masked = zt == MASK # [B, L] where the loss lives.
111
+ score = planner_score(logits.softmax(-1)[..., :DNA_SIZE]).detach() # No gradient through these.
112
+ weights = planner_weights(score, masked, strength).clamp(min=0.0) # [B, L] lambda_i.
113
+ return ((1.0 / t)[:, None] * weights * ce * masked).sum(-1).mean() # Nats per sequence.
114
+
115
+
116
+ # %% 06. Sampling with and without the planner
117
+ #
118
+ # The number revealed per step follows the schedule: after a step down to s, the masked count is
119
+ # the one the survival probability calls for, which is s L on the linear schedule.
120
+ @torch.no_grad() # Generation needs no gradient tracking.
121
+ def sample(model: torch.nn.Module, batch: int, length: int, steps: int = 32,
122
+ n_remask: int = 0, kind: str = "confidence", planner: bool = True) -> torch.Tensor:
123
+ """Generate from all masks. With planner false the reveals are drawn uniformly at random."""
124
+ device = next(model.parameters()).device
125
+ z = torch.full((batch, length), MASK, device=device) # [B, L] the all-mask start.
126
+ ts = torch.linspace(1.0, TIME_FLOOR, steps + 1, device=device) # The reverse grid.
127
+ for t, s in zip(ts[:-1], ts[1:]):
128
+ target = int(round(float(s) * length)) # The masked count the schedule calls for at s.
129
+ current = int(z.eq(MASK).sum(dim=1).max()) # The largest masked count in the batch.
130
+ n_unmask = max(current - target, 0) + n_remask # Enough to reach the target after remasking.
131
+ if planner:
132
+ z = planner_step(model, z, t.expand(batch), n_unmask, n_remask, kind)
133
+ else: # The uniform order of the plain sampler, with the same number of reveals.
134
+ z = _random_step(model, z, t.expand(batch), n_unmask)
135
+ logits = model(z, ts[-1].expand(batch)) # The last step runs down to s = 0, where r = 1.
136
+ logits[..., MASK] = -1e9
137
+ return torch.where(z == MASK, torch.distributions.Categorical(logits=logits).sample(), z)
138
+
139
+
140
+ def _random_step(model: torch.nn.Module, z: torch.Tensor, t: torch.Tensor,
141
+ n_unmask: int) -> torch.Tensor:
142
+ """The same step with the masked positions ranked by a uniform draw in place of the score."""
143
+ logits = model(z, t) # [B, L, 5].
144
+ logits[..., MASK] = -1e9 # The zero-mask rule.
145
+ x0 = torch.distributions.Categorical(logits=logits).sample() # [B, L].
146
+ noise = torch.rand(z.shape, device=z.device) # [B, L] an order with no preference.
147
+ return torch.where(topk_where(noise, z == MASK, n_unmask), x0, z)
148
+
149
+
150
+ # %% 07. The report
151
+ def main() -> None:
152
+ parser = common_parser(__doc__.split("\n")[0])
153
+ parser.add_argument("--remask", type=int, default=1, help="positions the corrector sends back")
154
+ parser.add_argument("--planner-strength", type=float, default=1.0,
155
+ help="0 gives the MDLM loss, 1 gives the PAPL weights")
156
+ parser.add_argument("--score", choices=["confidence", "entropy"], default="confidence")
157
+ # Two samplers run on the same weights, so the default trains 1000 steps and measures 64
158
+ # sequences in each order, which keeps the run inside about a minute.
159
+ parser.set_defaults(steps=1000, sample_steps=32, samples=64)
160
+ args = parser.parse_args()
161
+ set_seed(args.seed)
162
+ device = pick_device(args.device)
163
+ talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
164
+
165
+ # Section 02, the worked selection on a six-position sequence.
166
+ scores = torch.tensor([[0.42, 0.88, 0.91, 0.55, 0.31, 0.78]]) # [1, 6] one score per position.
167
+ masked = torch.tensor([[True, False, True, True, False, True]]) # Positions 1, 3, 4 and 6.
168
+ revealed = topk_where(scores, masked, 2).nonzero()[:, 1] + 1 # The two best masked positions.
169
+ returned = topk_where(-scores, ~masked, 1).nonzero()[:, 1] + 1 # The worst visible position.
170
+ talk(f"planner selection reveals {sorted(int(i) for i in revealed)} remasks "
171
+ f"{sorted(int(i) for i in returned)}")
172
+
173
+ # Section 04, the two fluxes across the boundary between visible and masked.
174
+ talk("corrector flux " + " ".join(
175
+ f"t={t}: beta flux {a:.1f}, w flux {b:.1f}" for t in (0.5, 0.25)
176
+ for a, b in [corrector_fluxes(t)]))
177
+
178
+ # Section 05, the worked PAPL weights on a state with four masked positions.
179
+ rho = torch.tensor([[0.10, 0.50, 0.15, 0.25]]) # [1, 4] the planner's distribution.
180
+ cross_entropies = torch.tensor([[1.2, 0.3, 0.9, 0.5]]) # [1, 4] nats at each masked position.
181
+ lam = 4.0 * rho # [1, 4] lambda_i = M rho_i, which averages to one.
182
+ talk(f"PAPL weights {[round(float(v), 1) for v in lam[0]]} sum {float(lam.sum()):.1f} "
183
+ f"MDLM inner sum {float(cross_entropies.sum()):.2f} PAPL inner sum "
184
+ f"{float((lam * cross_entropies).sum()):.2f}")
185
+
186
+ # Train the denoiser with the planner weights inside the loss.
187
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
188
+ train_tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
189
+ valid_tokens = splits["valid_tokens"].to(device) # [M, L] held out for the loss below.
190
+ model = SequenceDenoiser(VOCAB, args.length, width=args.width).to(device)
191
+ talk(f"device={device} parameters={count_parameters(model):,} strength={args.planner_strength}")
192
+
193
+ def step_loss(step: int) -> torch.Tensor:
194
+ rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device)
195
+ return papl_loss(model, train_tokens[rows], args.planner_strength)
196
+
197
+ losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
198
+ with torch.no_grad():
199
+ weighted = sum(float(papl_loss(model, valid_tokens, args.planner_strength))
200
+ for _ in range(4)) / 4.0
201
+ plain = sum(float(papl_loss(model, valid_tokens, 0.0)) for _ in range(4)) / 4.0
202
+
203
+ # Sample the same weights twice, once in a uniform order and once in the planner's order.
204
+ runs = {}
205
+ for name, planner, remask in (("uniform order", False, 0),
206
+ ("planner order", True, args.remask)):
207
+ torch.manual_seed(args.seed + 1) # The same draws for both samplers.
208
+ runs[name] = sample(model, args.samples, args.length, args.sample_steps,
209
+ remask, args.score, planner)
210
+
211
+ report = {
212
+ "revealed positions": sorted(int(i) for i in revealed),
213
+ "remasked position": sorted(int(i) for i in returned),
214
+ "flux at t = 0.5": corrector_fluxes(0.5)[0],
215
+ "flux at t = 0.25": corrector_fluxes(0.25)[1],
216
+ "PAPL weight at the planner's pick": float(lam[0, 1]),
217
+ "MDLM inner sum": float(cross_entropies.sum()),
218
+ "PAPL inner sum": float((lam * cross_entropies).sum()),
219
+ "training loss, last 50": sum(losses[-50:]) / len(losses[-50:]),
220
+ "validation PAPL loss": weighted,
221
+ "validation MDLM loss": plain,
222
+ "corrector remasks per step": args.remask,
223
+ }
224
+ for name, drawn in runs.items():
225
+ report[f"GC fraction, {name}"] = gc_fraction(drawn)
226
+ report[f"motif fraction, {name}"] = motif_fraction(drawn)
227
+ report[f"distinct, {name}"] = unique_fraction(drawn)
228
+ report["GC fraction in the data"] = gc_fraction(train_tokens)
229
+ report["motif fraction in the data"] = motif_fraction(train_tokens)
230
+ report["letter entropy, planner order"] = token_entropy(runs["planner order"], DNA_SIZE)
231
+ print_report("planner: the order of the reveals", report, decode_dna(runs["planner order"]))
232
+ save_run(args.out, model=model, config=vars(args), report=report,
233
+ losses=losses, samples=decode_dna(runs["planner order"]))
234
+
235
+
236
+ # %% 08. Run the script
237
+ if __name__ == "__main__": # Execute when launched directly.
238
+ main() # Work the selection, train with the planner weights, and sample both ways.
lecture_4/search.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Guiding masked diffusion on DNA by search over the tree of partial sequences.
2
+
3
+ For a property computed by an external scorer there is no classifier at intermediate noise levels,
4
+ so the value function is estimated by finishing the sequence and scoring the result. The partial
5
+ sequences form a tree whose edges are single reveals, selection walks it by an upper-confidence
6
+ rule, a rollout scores a new leaf, and the reward propagates back up the branch. Two objectives
7
+ that no single number orders are kept in a Pareto archive.
8
+
9
+ Chapter 4, "Guiding generation by search" and "Search over the tree of partial sequences". The
10
+ fine-tuning of "Keeping the results of the search" is left to the chapter. Time runs from t = 0
11
+ clean to t = 1 all masks, the survival probability is alpha_t = 1 - t, and the rates R_t(x, y)
12
+ act on probability rows, p_dot = p R.
13
+ Trained on the synthetic DNA of cis6270.data. Prints the worked value estimate, the worked archive,
14
+ and the archive the search finds.
15
+
16
+ python lecture_4/search.py # train, then search
17
+ python lecture_4/search.py --searches 64 # a larger tree
18
+ python lecture_4/search.py --sample-steps 8 # longer rollouts behind every value
19
+ python lecture_4/search.py --steps 20 # a few seconds, for a first look
20
+ """
21
+
22
+ # %% 01. Imports and configuration
23
+ import math # The upper-confidence rule is a square root of a logarithm.
24
+
25
+ import torch # Tensors, gradients and the categorical draws.
26
+ import torch.nn.functional as F # Cross-entropy at every position.
27
+
28
+ from cis6270.data import DNA_SIZE, MOTIF, decode_dna, dna_splits, encode_dna
29
+ from cis6270.metrics import gc_fraction, motif_fraction, unique_fraction
30
+ from cis6270.nets import SequenceDenoiser, count_parameters # The denoiser d_theta(. | z, t).
31
+ from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
32
+
33
+ MASK = DNA_SIZE # A, C, G, T are 0 to 3, and token 4 is the mask m.
34
+ VOCAB = DNA_SIZE + 1 # The masked alphabet V_m has K + 1 = 5 symbols.
35
+ TIME_FLOOR = 1e-3 # The reveal rate w(t) = 1/t is unbounded at t = 0.
36
+ EXPLORATION = 1.0 # c_uct, the weight on the visit-count term of the selection rule.
37
+
38
+
39
+ # %% 02. The two toy objectives
40
+ #
41
+ # Binding, solubility and permeability do not reduce to one number without a weighting that fixes
42
+ # the trade-off in advance. Two scores stand in for them here, a GC fraction and the best partial
43
+ # match to the planted motif, both in [0, 1] and neither implying the other.
44
+ def objectives(tokens: torch.Tensor) -> torch.Tensor:
45
+ """Returns [N, 2]: the GC fraction of each sequence and its best match to the motif."""
46
+ gc = ((tokens == 1) | (tokens == 2)).to(torch.get_default_dtype()).mean(dim=1) # [N] C and G.
47
+ motif = encode_dna([MOTIF])[0].to(tokens.device) # [4] the planted motif ACGT.
48
+ windows = tokens.unfold(1, len(motif), 1) # [N, L - 3, 4] every window of the motif's length.
49
+ hits = (windows == motif).to(torch.get_default_dtype()).mean(dim=2) # [N, L - 3] per window.
50
+ match = hits.max(dim=1).values # [N] the best window of each sequence.
51
+ return torch.stack([gc, match], dim=1) # [N, 2] one row per sequence.
52
+
53
+
54
+ # %% 03. Rollouts as a value estimate
55
+ #
56
+ # h_t(z) is the probability that a sequence continuing from z ends with the property, so running
57
+ # the sampler to completion N times and averaging the reward estimates it without bias, with a
58
+ # variance that falls like 1/N.
59
+ @torch.no_grad() # Rollouts need no gradient tracking.
60
+ def complete(model: torch.nn.Module, z: torch.Tensor, t: float, steps: int = 3) -> torch.Tensor:
61
+ """Run the masked sampler from z at time t down to clean DNA. z: [N, L] -> [N, L]."""
62
+ device = z.device
63
+ ts = torch.linspace(t, TIME_FLOOR, steps + 1, device=device) # The remaining reverse grid.
64
+ for now, nxt in zip(ts[:-1], ts[1:]):
65
+ logits = model(z, now.expand(len(z))) # [N, L, 5] the clean-token prediction.
66
+ logits[..., MASK] = -1e9 # The zero-mask rule: no posterior mass belongs on m.
67
+ x0 = torch.distributions.Categorical(logits=logits).sample() # [N, L].
68
+ unmask = torch.rand(z.shape, device=device) < (now - nxt) / now # [N, L] the reveal draw.
69
+ z = torch.where((z == MASK) & unmask, x0, z) # Carry-over unmasking.
70
+ logits = model(z, ts[-1].expand(len(z))) # The last step runs down to s = 0, where r = 1.
71
+ logits[..., MASK] = -1e9
72
+ return torch.where(z == MASK, torch.distributions.Categorical(logits=logits).sample(), z)
73
+
74
+
75
+ def value_estimate(model: torch.nn.Module, state: torch.Tensor, t: float, rollouts: int,
76
+ steps: int = 3) -> tuple[torch.Tensor, torch.Tensor]:
77
+ """Average the reward over N completions of one partial sequence. Returns [2] and [N, L]."""
78
+ batch = state[None, :].repeat(rollouts, 1) # [N, L] the same state, N independent endings.
79
+ finished = complete(model, batch, 1.0 if t >= 1.0 else t, steps) # [N, L] clean sequences.
80
+ return objectives(finished).mean(dim=0), finished # [2] the value, and what produced it.
81
+
82
+
83
+ # %% 04. The Pareto archive
84
+ #
85
+ # A candidate worse on every axis than one already kept is dominated and discarded, and the rest
86
+ # stay, since each of them beats every other on at least one axis.
87
+ class ParetoArchive:
88
+ """Every nondominated sequence the search has produced, with its objective vector."""
89
+
90
+ def __init__(self) -> None:
91
+ self.entries: list[tuple[tuple[int, ...], tuple[float, ...]]] = []
92
+
93
+ def add(self, sequence: tuple[int, ...], scores: tuple[float, ...]) -> None:
94
+ """Keep the candidate when nothing in the archive beats it on every axis."""
95
+ for _, kept in self.entries:
96
+ if all(k >= s for k, s in zip(kept, scores)) and kept != scores:
97
+ return # Dominated, so it adds nothing.
98
+ self.entries = [(q, kept) for q, kept in self.entries # Drop what the newcomer dominates.
99
+ if not (all(s >= k for s, k in zip(scores, kept)) and kept != scores)]
100
+ if sequence not in {q for q, _ in self.entries}:
101
+ self.entries.append((sequence, scores))
102
+
103
+
104
+ # %% 05. The tree of partial sequences
105
+ #
106
+ # Selection walks from the root to a leaf by the upper-confidence rule, expansion adds children
107
+ # drawn from the denoiser perturbed by Gumbel noise, a rollout scores one of them, and the reward
108
+ # propagates back up the branch so that every node on it accumulates visits.
109
+ class Node:
110
+ """A partially unmasked sequence, with the visits and rewards the search has accumulated."""
111
+
112
+ def __init__(self, state: torch.Tensor, depth: int, parent=None):
113
+ self.state, self.depth, self.parent = state, depth, parent
114
+ self.children: list[Node] = []
115
+ self.visits, self.total = 0, 0.0 # The visit count and the summed scalar reward.
116
+
117
+ @property
118
+ def mean(self) -> float:
119
+ """The mean reward at this node, which the selection rule weighs against its visit count."""
120
+ return self.total / self.visits if self.visits else 0.0
121
+
122
+ def best_child(self):
123
+ """argmax of r_bar_j + c sqrt(log n / n_j), the usual upper-confidence rule."""
124
+ return max(self.children, key=lambda child: child.mean + EXPLORATION * math.sqrt(
125
+ math.log(max(self.visits, 1)) / child.visits) if child.visits else float("inf"))
126
+
127
+
128
+ @torch.no_grad() # The expansion needs no gradient tracking.
129
+ def expand(model: torch.nn.Module, node: Node, t: float, branch: int) -> None:
130
+ """Add children by revealing one masked position, drawn from the denoiser plus Gumbel noise."""
131
+ masked = (node.state == MASK).nonzero().flatten() # [m] the positions a reveal may act on.
132
+ if len(masked) == 0:
133
+ return # A complete sequence has no children.
134
+ logits = model(node.state[None], torch.tensor([t], device=node.state.device))[0] # [L, 5].
135
+ logits[..., MASK] = -1e9 # The zero-mask rule.
136
+ gumbel = -torch.log(-torch.log(torch.rand_like(logits).clamp(min=1e-20))) # [L, 5].
137
+ perturbed = (logits + gumbel).masked_fill( # Taking maxima of this samples the denoiser.
138
+ (node.state != MASK)[:, None], float("-inf"))
139
+ flat = perturbed[:, :DNA_SIZE].flatten() # [L K] one score per position and clean token.
140
+ for index in flat.topk(min(branch, int(len(masked)) * DNA_SIZE)).indices: # Distinct reveals.
141
+ child_state = node.state.clone() # [L] the parent with one more position filled.
142
+ child_state[int(index) // DNA_SIZE] = int(index) % DNA_SIZE
143
+ node.children.append(Node(child_state, node.depth + 1, node))
144
+
145
+
146
+ def backup(node: Node, reward: float) -> None:
147
+ """Add one rollout's reward to every node on the branch from here to the root."""
148
+ while node is not None:
149
+ node.visits, node.total = node.visits + 1, node.total + reward
150
+ node = node.parent
151
+
152
+
153
+ # %% 06. The report
154
+ def main() -> None:
155
+ parser = common_parser(__doc__.split("\n")[0])
156
+ parser.add_argument("--searches", type=int, default=24, help="tree expansions")
157
+ parser.add_argument("--branch", type=int, default=3, help="children added at each expansion")
158
+ parser.add_argument("--rollouts", type=int, default=6, help="completions scored per leaf")
159
+ # Every leaf is scored by its own rollouts, so --sample-steps is the length of one rollout and
160
+ # stays short. Training rises to 1000 steps, which keeps the run inside about a minute.
161
+ parser.set_defaults(steps=1000, sample_steps=3)
162
+ args = parser.parse_args()
163
+ set_seed(args.seed)
164
+ device = pick_device(args.device)
165
+ talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
166
+
167
+ # Section 03, the worked value estimate and the reweighting it produces.
168
+ at_parent = float(torch.tensor([0.1, 0.8, 0.3, 0.2]).mean()) # Four rollouts from z.
169
+ at_child = float(torch.tensor([0.7, 0.9, 0.6, 0.8]).mean()) # Four rollouts from its child.
170
+ factor = at_child / at_parent # The h-transform factor on that edge.
171
+ sibling = 0.2 / at_parent # A child whose rollouts averaged 0.2 is slowed by this factor.
172
+ talk(f"value estimates h(z) = {at_parent:.2f} h(z') = {at_child:.2f} factor "
173
+ f"{factor:.3f} a model rate of 1.2 becomes {1.2 * factor:.3f}, and a sibling at 0.2 is "
174
+ f"slowed by {sibling:.3f}")
175
+
176
+ # Section 04, the worked archive on three candidates scored on two axes.
177
+ worked = ParetoArchive()
178
+ for name, scores in (("P1", (0.8, 0.3)), ("P2", (0.5, 0.7)), ("P3", (0.4, 0.2))):
179
+ worked.add(tuple(ord(c) for c in name), scores) # The name stands in for a sequence.
180
+ kept = [chr(q[0]) + chr(q[1]) for q, _ in worked.entries] # Which candidates survived.
181
+ talk(f"worked archive keeps {kept}, so P3 is dominated by P1 on both axes")
182
+
183
+ # Train the masked diffusion model the search will guide.
184
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
185
+ train_tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
186
+ model = SequenceDenoiser(VOCAB, args.length, width=args.width).to(device)
187
+ talk(f"device={device} parameters={count_parameters(model):,} searches={args.searches}")
188
+
189
+ def step_loss(step: int) -> torch.Tensor:
190
+ """The masked diffusion loss, unchanged: the search reweights rates, not weights."""
191
+ rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device)
192
+ x0 = train_tokens[rows]
193
+ t = torch.rand(len(x0), device=device).clamp(min=TIME_FLOOR) # [B] one noise level each.
194
+ zt = torch.where(torch.rand(x0.shape, device=device) < (1.0 - t)[:, None], x0, MASK)
195
+ logits = model(zt, t) # [B, L, 5].
196
+ logits[..., MASK] = -1e9 # The zero-mask rule.
197
+ ce = F.cross_entropy(logits.transpose(1, 2), x0, reduction="none") # [B, L].
198
+ return ((1.0 / t)[:, None] * ce * (zt == MASK)).sum(-1).mean()
199
+
200
+ losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
201
+
202
+ # Run the search, keeping every completed rollout that is not dominated.
203
+ torch.manual_seed(args.seed + 1) # Fix the Gumbel draws and the rollouts.
204
+ root = Node(torch.full((args.length,), MASK, device=device), 0)
205
+ archive, scored = ParetoArchive(), [] # The archive and every rollout's objective vector.
206
+ for _ in range(args.searches):
207
+ node = root # Selection walks from the root down the tree already built.
208
+ while node.children:
209
+ node = node.best_child()
210
+ time_here = max(1.0 - node.depth / args.length, TIME_FLOOR) # The level at this depth.
211
+ expand(model, node, time_here, args.branch) # Expansion adds children by one reveal each.
212
+ leaf = node.children[0] if node.children else node # Score the first new child.
213
+ value, finished = value_estimate(model, leaf.state, time_here, args.rollouts,
214
+ args.sample_steps)
215
+ backup(leaf, float(value.mean())) # One scalar reward guides the selection rule.
216
+ for sequence, score in zip(finished.tolist(), objectives(finished).tolist()):
217
+ archive.add(tuple(sequence), tuple(round(v, 4) for v in score)) # Keep the best front.
218
+ scored.append(score)
219
+
220
+ found = torch.tensor([s for _, s in archive.entries]) # [A, 2] the archive's objectives.
221
+ everything = torch.tensor(scored) # [R, 2] every rollout the search scored.
222
+ best = torch.tensor([list(q) for q, _ in archive.entries], device=device) # [A, L] sequences.
223
+ report = {
224
+ "value at the parent": at_parent,
225
+ "value at the child": at_child,
226
+ "reweighting factor": factor,
227
+ "guided rate from 1.2": 1.2 * factor,
228
+ "factor at a sibling scoring 0.2": sibling,
229
+ "worked archive size": len(worked.entries),
230
+ "training loss, last 50": sum(losses[-50:]) / len(losses[-50:]),
231
+ "rollouts scored": len(scored),
232
+ "archive size": len(archive.entries),
233
+ "best GC in the archive": float(found[:, 0].max()),
234
+ "best motif match in the archive": float(found[:, 1].max()),
235
+ "mean GC over all rollouts": float(everything[:, 0].mean()),
236
+ "mean motif match over all rollouts": float(everything[:, 1].mean()),
237
+ "rollout length, steps": args.sample_steps,
238
+ "GC fraction in the data": gc_fraction(train_tokens),
239
+ "motif fraction in the data": motif_fraction(train_tokens),
240
+ "motif fraction in the archive": motif_fraction(best),
241
+ "distinct in the archive": unique_fraction(best),
242
+ }
243
+ print_report("search: a Pareto archive over partial sequences", report, decode_dna(best))
244
+ save_run(args.out, model=model, config=vars(args), report=report,
245
+ losses=losses, samples=decode_dna(best))
246
+
247
+
248
+ # %% 07. Run the script
249
+ if __name__ == "__main__": # Execute when launched directly.
250
+ main() # Work the value estimate, train the model, and search under the h-transform.
lecture_4/sedd.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SEDD on DNA, a network that predicts the ratios the reverse rates are built from.
2
+
3
+ The reverse rate at a position is a forward rate times the ratio p_t(z with position i set to a)
4
+ divided by p_t(z), and the two models before this one reached that ratio through a denoiser over
5
+ clean tokens and a sum against the forward kernel. Here the network outputs the ratio itself, and
6
+ the rate error becomes score entropy, whose minimum over the prediction sits at the true ratio.
7
+
8
+ Chapter 4, "The ratio we have to learn" and "Predicting the ratios directly", on the uniform member
9
+ of the pi family. Time runs from t = 0 clean to t = 1 noise, the survival probability is
10
+ alpha_t = exp(-beta t), and the rates R_t(x, y) act on probability rows, p_dot = p R. Trained on the
11
+ synthetic DNA of cis6270.data. Prints the worked score entropy at three predictions and the sample
12
+ statistics.
13
+
14
+ python lecture_4/sedd.py # train and generate
15
+ python lecture_4/sedd.py --steps 20 # a few seconds, for a first look
16
+ """
17
+
18
+ # %% 01. Imports and configuration
19
+ import math # The worked score entropies are logarithms.
20
+
21
+ import torch # Tensors, gradients and the categorical draws.
22
+
23
+ from cis6270.data import DNA_SIZE, decode_dna, dna_splits # ACGT, K = 4 and the shared sequences.
24
+ from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
25
+ from cis6270.nets import SequenceDenoiser, count_parameters # Reused as L heads of K outputs.
26
+ from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
27
+
28
+ BETA = 4.0 # The constant noise rate, so alpha_t = exp(-4t) and the forward rate is BETA/K.
29
+ TIME_FLOOR = 2e-2 # The true ratios grow like 1/(1 - alpha_t), so sampling stops just above zero.
30
+ MAX_RATIO = 1e4 # The predicted ratios are exponentials, kept finite for the sampler.
31
+
32
+
33
+ # %% 02. The concrete score
34
+ #
35
+ # s_t(z)_{i,a} = p_t(z^{i <- a}) / p_t(z) holds L(K - 1) numbers per sequence, one per position and
36
+ # per alternative token, which is exactly the shape of a network with L heads of K logits.
37
+ def concrete_score(model: torch.nn.Module, z: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
38
+ """s_theta(z)_{i,a}, positive and with no normalization constraint. Returns [B, L, K]."""
39
+ return model(z, t).clamp(max=math.log(MAX_RATIO)).exp() # [B, L, K] exponentiate the logits.
40
+
41
+
42
+ def kernel_ratio(x0: torch.Tensor, z: torch.Tensor, alpha: torch.Tensor) -> torch.Tensor:
43
+ """The conditional target r = q_t(a | x_0) / q_t(z^i | x_0), one per destination. [B, L, K]."""
44
+ uniform = (1.0 - alpha) / DNA_SIZE # [B, 1] the redrawn share at this noise level.
45
+ numerator = uniform[..., None] + alpha[..., None] * torch.nn.functional.one_hot(
46
+ x0, DNA_SIZE) # [B, L, K] q_t(a | x_0) at every destination a.
47
+ denominator = numerator.gather(2, z[:, :, None]) # [B, L, 1] the same kernel at the current token.
48
+ return numerator / denominator # [B, L, K] the ratio the network has to reproduce.
49
+
50
+
51
+ # %% 03. Denoising score entropy
52
+ #
53
+ # Writing the target rate as lambda r and the model rate as lambda s turns the rate error into
54
+ # lambda [s - r log s + r(log r - 1)]. The first two terms hold every dependence on the prediction,
55
+ # a linear term against a logarithmic one, so the minimum over s at fixed r sits at s = r.
56
+ def score_entropy(target: torch.Tensor, predicted: torch.Tensor,
57
+ forward_rate: float = 1.0) -> torch.Tensor:
58
+ """l(lambda r, lambda s), elementwise, which vanishes at s = r."""
59
+ constant = target * (target.clamp(min=1e-20).log() - 1.0) # r(log r - 1), free of the prediction.
60
+ return forward_rate * (predicted - target * predicted.clamp(min=1e-20).log() + constant)
61
+
62
+
63
+ def sedd_loss(model: torch.nn.Module, x0: torch.Tensor) -> torch.Tensor:
64
+ """One Monte Carlo sample of the score entropy, summed over destinations and positions."""
65
+ t = torch.rand(x0.shape[0], 1, device=x0.device).clamp(min=TIME_FLOOR) # [B, 1] one time each.
66
+ alpha = torch.exp(-BETA * t) # [B, 1] the survival probability at that time.
67
+ keep = torch.rand(x0.shape, device=x0.device) < alpha # [B, L] survival of the original base.
68
+ fresh = torch.randint(0, DNA_SIZE, x0.shape, device=x0.device) # [B, L] a draw from pi.
69
+ z = torch.where(keep, x0, fresh) # [B, L] uniform replacement, with no marker of the damage.
70
+ target = kernel_ratio(x0, z, alpha) # [B, L, K] the ratio attached to this clean sequence.
71
+ predicted = concrete_score(model, z, t[:, 0]) # [B, L, K] the network's own ratios.
72
+ current = torch.nn.functional.one_hot(z, DNA_SIZE).bool() # [B, L, K] the token already in place.
73
+ error = score_entropy(target, predicted, BETA / DNA_SIZE) # [B, L, K] one term per jump.
74
+ return error.masked_fill(current, 0.0).sum((1, 2)).mean() # Nats per sequence.
75
+
76
+
77
+ # %% 04. Sampling from the predicted ratios
78
+ #
79
+ # The reverse rate is the forward rate of the jump that would undo it, times the predicted ratio,
80
+ # and tau-leaping turns each rate into a jump probability over a step of length h.
81
+ @torch.no_grad() # Generation needs no gradient tracking.
82
+ def sample(model: torch.nn.Module, batch: int, length: int, steps: int = 64) -> torch.Tensor:
83
+ """Generate from uniformly random tokens down to clean DNA. Returns [B, L] over ACGT."""
84
+ device = next(model.parameters()).device
85
+ z = torch.randint(0, DNA_SIZE, (batch, length), device=device) # [B, L] a draw from pi.
86
+ ts = torch.linspace(1.0, TIME_FLOOR, steps + 1, device=device) # The reverse grid.
87
+ for t, s in zip(ts[:-1], ts[1:]):
88
+ rates = (BETA / DNA_SIZE) * concrete_score(model, z, t.expand(batch)) # [B, L, K].
89
+ jump = (t - s) * rates # [B, L, K] the tau-leaping probability of each jump.
90
+ jump.scatter_(2, z[:, :, None], 0.0) # A jump to the current base is not a jump.
91
+ jump = jump / jump.sum(-1, keepdim=True).clamp(min=1.0) # Rescale an oversized step.
92
+ stay = (1.0 - jump.sum(-1, keepdim=True)).clamp(min=0.0) # [B, L, 1] the chance of staying.
93
+ choice = torch.distributions.Categorical(
94
+ probs=torch.cat([jump, stay], dim=-1)).sample() # [B, L] destination, or K for staying.
95
+ z = torch.where(choice == DNA_SIZE, z, choice.clamp(max=DNA_SIZE - 1)) # [B, L].
96
+ return z # [B, L] clean DNA, since uniform replacement never leaves the alphabet.
97
+
98
+
99
+ # %% 05. The report
100
+ def main() -> None:
101
+ parser = common_parser(__doc__.split("\n")[0])
102
+ # The ratios need a longer budget than the worked numbers do, so the default trains for 1000
103
+ # steps and measures on 64 sequences, which keeps the run inside about a minute.
104
+ parser.set_defaults(steps=1000, sample_steps=64, samples=64)
105
+ args = parser.parse_args()
106
+ set_seed(args.seed)
107
+ device = pick_device(args.device)
108
+ talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
109
+
110
+ # Section 02, the target ratio of the running example, reached through the denoiser route.
111
+ posterior = torch.tensor([0.375, 0.625, 0.0, 0.0]) # p(x_0 | X_t = C) at alpha_t = 1/2.
112
+ per_clean = torch.tensor([5.0, 0.2, 1.0, 1.0]) # q_t(A | b) / q_t(C | b) for b over ACGT.
113
+ target_ratio = float(posterior @ per_clean) # The ratio in the A direction, which comes out 2.
114
+
115
+ # Section 03, the score entropy at three predictions of that ratio.
116
+ errors = {s: float(score_entropy(torch.tensor(target_ratio), torch.tensor(s)))
117
+ for s in (1.8, 1.0, 3.0)} # A ten percent error against two larger ones.
118
+ talk(f"target ratio {target_ratio:.1f} from 0.375(5) + 0.625(0.2)")
119
+ at_target = float(score_entropy(torch.tensor(target_ratio), torch.tensor(target_ratio)))
120
+ talk("score entropy " + " ".join(f"s={s}: {v:.4f}" for s, v in errors.items()) +
121
+ f" at s = r it is {at_target:.1e}")
122
+
123
+ # Train the network to output the ratios.
124
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
125
+ train_tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
126
+ valid_tokens = splits["valid_tokens"].to(device) # [M, L] held out for the loss below.
127
+ model = SequenceDenoiser(DNA_SIZE, args.length, width=args.width).to(device)
128
+ talk(f"device={device} parameters={count_parameters(model):,} ratios per sequence "
129
+ f"{args.length * (DNA_SIZE - 1)}")
130
+
131
+ def step_loss(step: int) -> torch.Tensor:
132
+ rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device)
133
+ return sedd_loss(model, train_tokens[rows]) # One clean batch into one scalar.
134
+
135
+ losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
136
+ with torch.no_grad():
137
+ held_out = sum(float(sedd_loss(model, valid_tokens)) for _ in range(4)) / 4.0
138
+
139
+ # The ratios the trained network predicts at a half-corrupted batch, against their targets.
140
+ with torch.no_grad():
141
+ check_t = torch.full((len(valid_tokens), 1), math.log(2.0) / BETA, device=device)
142
+ check_alpha = torch.exp(-BETA * check_t) # alpha_t = 1/2 at this time.
143
+ keep = torch.rand(valid_tokens.shape, device=device) < check_alpha
144
+ noisy = torch.where(keep, valid_tokens,
145
+ torch.randint(0, DNA_SIZE, valid_tokens.shape, device=device))
146
+ predicted = concrete_score(model, noisy, check_t[:, 0]) # [M, L, K] the predicted ratios.
147
+ exact = kernel_ratio(valid_tokens, noisy, check_alpha) # [M, L, K] the conditional targets.
148
+ toward_clean = exact > 1.0 # The jumps that point back at the clean base.
149
+ mean_predicted = float(predicted[toward_clean].mean()) # Should sit below the target 5.
150
+
151
+ torch.manual_seed(args.seed + 1) # Fix the jump draws, so the samples repeat.
152
+ samples = sample(model, args.samples, args.length, args.sample_steps) # [B, L] over ACGT.
153
+ report = {
154
+ "target ratio at C toward A": target_ratio,
155
+ "score entropy at s = 1.8": errors[1.8],
156
+ "score entropy at s = 1.0": errors[1.0],
157
+ "score entropy at s = 3.0": errors[3.0],
158
+ "training score entropy, last 50": sum(losses[-50:]) / len(losses[-50:]),
159
+ "validation score entropy": held_out,
160
+ "mean predicted ratio toward clean": mean_predicted,
161
+ "conditional target toward clean": float(exact[toward_clean].mean()),
162
+ "GC fraction": gc_fraction(samples),
163
+ "GC fraction in the data": gc_fraction(train_tokens),
164
+ "motif fraction": motif_fraction(samples),
165
+ "motif fraction in the data": motif_fraction(train_tokens),
166
+ "distinct samples": unique_fraction(samples),
167
+ "letter entropy, nats": token_entropy(samples, DNA_SIZE),
168
+ }
169
+ print_report("sedd: score entropy on DNA", report, decode_dna(samples))
170
+ save_run(args.out, model=model, config=vars(args), report=report,
171
+ losses=losses, samples=decode_dna(samples))
172
+
173
+
174
+ # %% 06. Run the script
175
+ if __name__ == "__main__": # Execute when launched directly.
176
+ main() # Work the score entropy, train the ratios, and generate.
lecture_4/simplex.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Simplex diffusion on DNA, a belief at every position in place of a token.
2
+
3
+ Every model before this one draws a token at each reverse step and discards the rest of the
4
+ denoiser's distribution, so a position the model was unsure about looks the same as one it was
5
+ certain about. Here the state at a position is a probability vector over the four bases, the
6
+ corruption is a Dirichlet around the uniform-replacement kernel, and one reverse step thins the
7
+ current belief, draws an innovation and mixes the two.
8
+
9
+ Chapter 4, "From tokens to beliefs" and "Thinning, an innovation, and a mixture". Time runs from
10
+ t = 0 clean to t = 1 noise, the survival probability is alpha_t = 1 - t, the concentration is
11
+ c_t = gamma K / (1 - alpha_t), and the rates R_t(x, y) act on probability rows, p_dot = p R.
12
+ Trained on the synthetic DNA of cis6270.data. Prints the worked Dirichlet parameters, the worked
13
+ thinning weight and its Monte Carlo check, then the sample statistics.
14
+
15
+ python lecture_4/simplex.py # train and generate
16
+ python lecture_4/simplex.py --mixture-draws 10000 # a coarser check of the mixture
17
+ python lecture_4/simplex.py --steps 20 # a few seconds, for a first look
18
+ """
19
+
20
+ # %% 01. Imports and configuration
21
+ import torch # Tensors, gradients and the Dirichlet, Beta and categorical draws.
22
+ import torch.nn as nn # The denoiser whose input layer is the one thing that changes.
23
+ import torch.nn.functional as F # Cross-entropy at every position.
24
+
25
+ from cis6270.data import DNA_SIZE, decode_dna, dna_splits # ACGT, K = 4 and the shared sequences.
26
+ from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
27
+ from cis6270.nets import SequenceDenoiser, count_parameters # The transformer the belief feeds.
28
+ from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
29
+
30
+ GAMMA = 1.0 # The constant of the concentration schedule c_t = gamma K / (1 - alpha_t).
31
+ TIME_FLOOR = 1e-3 # The concentration grows like 1/(1 - alpha_t), so the grid stops above zero.
32
+
33
+
34
+ # %% 02. The forward marginal as a Dirichlet
35
+ #
36
+ # Fact 2 calls for a forward marginal in closed form, and the natural requirement is that the
37
+ # belief fluctuate around the marginal the token models already use,
38
+ # E[P_t | x_0] = alpha_t onehot(x_0) + (1 - alpha_t) pi, with the spread set by c_t.
39
+ def concentration(alpha: float | torch.Tensor) -> float | torch.Tensor:
40
+ """c_t = gamma K / (1 - alpha_t), the schedule that makes the increment nonnegative."""
41
+ return GAMMA * DNA_SIZE / (1.0 - alpha) # 8 at alpha_t = 0.5 and 20 at alpha_s = 0.8.
42
+
43
+
44
+ def belief_mean(alpha: float, x0: int) -> torch.Tensor:
45
+ """E[P_t | x_0] over (A, C, G, T), the uniform-replacement kernel of the token models. [K]."""
46
+ mean = torch.full((DNA_SIZE,), (1.0 - alpha) / DNA_SIZE) # [K] the share from pi.
47
+ mean[x0] = mean[x0] + alpha # The clean token keeps the survival mass on top of its share.
48
+ return mean # [K] a distribution, since alpha + (1 - alpha) = 1.
49
+
50
+
51
+ def dirichlet_parameters(alpha: float, x0: int) -> torch.Tensor:
52
+ """c_t E[P_t | x_0], the Dirichlet parameters at one position. Returns [K]."""
53
+ return concentration(alpha) * belief_mean(alpha, x0) # (5, 1, 1, 1) at alpha_t = 0.5.
54
+
55
+
56
+ def parameter_field(x0: torch.Tensor, alpha: torch.Tensor) -> torch.Tensor:
57
+ """The same parameters for a whole batch. x0: [B, L], alpha: [B, 1] -> [B, L, K].
58
+
59
+ Every coordinate other than the clean token has parameter gamma, constant in time, and the
60
+ clean coordinate has gamma (1 + K alpha_t/(1 - alpha_t)), the factor of the uniform rates.
61
+ """
62
+ off = torch.full(x0.shape + (DNA_SIZE,), GAMMA, device=x0.device) # [B, L, K] the pi share.
63
+ clean = GAMMA * DNA_SIZE * alpha / (1.0 - alpha) # [B, 1] the survival mass times c_t.
64
+ return off.scatter_add(2, x0[:, :, None], clean[..., None].expand_as(x0[:, :, None]))
65
+
66
+
67
+ def corrupt(x0: torch.Tensor, alpha: torch.Tensor) -> torch.Tensor:
68
+ """Draw P_t ~ Dir(c_t E[P_t | x_0]) at each position. x0: [B, L], alpha: [B, 1] -> [B, L, K]."""
69
+ return torch.distributions.Dirichlet(parameter_field(x0, alpha)).sample() # [B, L, K] beliefs.
70
+
71
+
72
+ # %% 03. Thinning, an innovation, and a mixture
73
+ #
74
+ # A Dirichlet with parameter a + b equals W V + (1 - W) U for V ~ Dir(a), U ~ Dir(b) and
75
+ # W ~ Beta(sum a, sum b). With this concentration schedule the increment b sits entirely on the
76
+ # clean coordinate, so the innovation U is the one-hot vector of the clean token.
77
+ def increment(alpha_t: float, alpha_s: float, x0: int) -> torch.Tensor:
78
+ """b = c_s E[P_s | x_0] - c_t E[P_t | x_0], nonnegative for alpha_s > alpha_t. Returns [K]."""
79
+ return dirichlet_parameters(alpha_s, x0) - dirichlet_parameters(alpha_t, x0) # (12, 0, 0, 0).
80
+
81
+
82
+ def thinning_law(alpha_t: float, alpha_s: float) -> tuple[float, float]:
83
+ """The two Beta parameters, sum a = c_t and sum b = c_s - c_t, which are 8 and 12 here."""
84
+ return float(concentration(alpha_t)), float(concentration(alpha_s) - concentration(alpha_t))
85
+
86
+
87
+ def reverse_step(belief: torch.Tensor, clean: torch.Tensor,
88
+ alpha_t: float, alpha_s: float) -> torch.Tensor:
89
+ """P_s = W P_t + (1 - W) onehot(x_0), one Beta draw per position. belief: [B, L, K]."""
90
+ first, second = thinning_law(alpha_t, alpha_s) # The two parameters of the thinning weight.
91
+ w = torch.distributions.Beta( # [B, L] one weight per position, with mean c_t / c_s.
92
+ torch.tensor(first, device=belief.device),
93
+ torch.tensor(second, device=belief.device)).sample(belief.shape[:2])
94
+ innovation = F.one_hot(clean, DNA_SIZE).to(belief.dtype) # [B, L, K] the corner it moves to.
95
+ return w[..., None] * belief + (1.0 - w[..., None]) * innovation # [B, L, K] the mixture.
96
+
97
+
98
+ # %% 04. The denoiser that reads a belief
99
+ #
100
+ # The only architectural change is at the input. A token is looked up in the embedding table, and
101
+ # a belief is the same K rows of that table combined in its own proportions, so a one-hot input
102
+ # recovers the denoiser of every other file in this lecture exactly.
103
+ class BeliefDenoiser(nn.Module):
104
+ """d_theta(. | P_t, t), the shared sequence denoiser reading K numbers at every position."""
105
+
106
+ def __init__(self, length: int, width: int = 128):
107
+ super().__init__()
108
+ self.inner = SequenceDenoiser(DNA_SIZE, length, width=width) # Embeddings and transformer.
109
+
110
+ def forward(self, beliefs: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
111
+ """beliefs: [B, L, K], t: [B] -> logits over the clean tokens [B, L, K]."""
112
+ inner = self.inner
113
+ positions = torch.arange(beliefs.shape[1], device=beliefs.device) # [L].
114
+ h = (beliefs @ inner.token_embedding.weight # [B, L, D] the belief mixes the K embeddings.
115
+ + inner.position_embedding(positions)[None, :, :] # [1, L, D] where it sits.
116
+ + inner.time_embedding(t)[:, None, :]) # [B, 1, D] how far along the path we are.
117
+ return inner.head(inner.transformer(h)) # [B, L, K] every position sees every other.
118
+
119
+
120
+ # %% 05. The loss
121
+ #
122
+ # Fact 5 is the cross-entropy whose optimum is the posterior over clean tokens. The reverse step
123
+ # carries the denoiser's answer through the mixture of Section 03, whose weight already comes from
124
+ # the concentration schedule, so the time derivative of alpha_t multiplies nothing here.
125
+ def loss(model: nn.Module, x0: torch.Tensor) -> torch.Tensor:
126
+ """One Monte Carlo sample of E[-log d_theta(x_0 | P_t, t)], summed over positions."""
127
+ t = torch.rand(x0.shape[0], 1, device=x0.device).clamp(min=TIME_FLOOR) # [B, 1] one time each.
128
+ alpha = 1.0 - t # [B, 1] the linear schedule, alpha_0 = 1 and alpha_1 = 0.
129
+ beliefs = corrupt(x0, alpha) # [B, L, K] one Dirichlet draw per position.
130
+ logits = model(beliefs, t[:, 0]) # [B, L, K] the denoiser reads the whole field of beliefs.
131
+ ce = F.cross_entropy(logits.transpose(1, 2), x0, reduction="none") # [B, L].
132
+ return ce.sum(-1).mean() # Nats per sequence, with every position counted.
133
+
134
+
135
+ # %% 06. The sampler
136
+ #
137
+ # Generation starts from Dir(gamma, ..., gamma), which at alpha_1 = 0 is the law the corruption
138
+ # ends at. Each step draws one clean token from the denoiser, thins the current belief and mixes
139
+ # the two, so the belief moves through the interior and reaches a corner at the last step.
140
+ @torch.no_grad() # Generation needs no gradient tracking.
141
+ def sample(model: nn.Module, batch: int, length: int, steps: int = 64) -> torch.Tensor:
142
+ """Generate from beliefs scattered around pi down to clean DNA. Returns [B, L] over ACGT."""
143
+ device = next(model.parameters()).device
144
+ start = torch.full((batch, length, DNA_SIZE), GAMMA, device=device) # [B, L, K] at alpha_1 = 0.
145
+ belief = torch.distributions.Dirichlet(start).sample() # [B, L, K] scattered around pi.
146
+ ts = torch.linspace(1.0, TIME_FLOOR, steps + 1, device=device) # The reverse grid, t down to 0.
147
+ for t, s in zip(ts[:-1], ts[1:]): # One interval [s, t] at a time.
148
+ logits = model(belief, t.expand(batch)) # [B, L, K] the clean-token prediction.
149
+ clean = torch.distributions.Categorical(logits=logits).sample() # [B, L] the innovation.
150
+ belief = reverse_step(belief, clean, float(1.0 - t), float(1.0 - s)) # [B, L, K].
151
+ return belief.argmax(-1) # [B, L] the corner each position has reached.
152
+
153
+
154
+ # %% 07. The report
155
+ def main() -> None:
156
+ parser = common_parser(__doc__.split("\n")[0])
157
+ parser.add_argument("--mixture-draws", type=int, default=400_000,
158
+ help="draws in the check of the thinning representation")
159
+ # The belief carries the denoiser's uncertainty between steps, which needs enough reverse steps
160
+ # to show. A default run trains 1000 steps and measures 64 sequences, inside about a minute.
161
+ parser.set_defaults(steps=1000, sample_steps=64, samples=64)
162
+ args = parser.parse_args()
163
+ set_seed(args.seed) # The same seed gives the same training batches and the same samples.
164
+ device = pick_device(args.device)
165
+ talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
166
+
167
+ # Section 02, the worked Dirichlet at the two noise levels of the chapter, with a clean A.
168
+ at_t = dirichlet_parameters(0.5, 0) # [K] (5, 1, 1, 1) at the later of the two times.
169
+ at_s = dirichlet_parameters(0.8, 0) # [K] (17, 1, 1, 1) at the earlier one.
170
+ total_t, total_s = at_t.sum(), at_s.sum() # c_t and c_s, the totals that set every variance.
171
+ spread_t = (at_t * (total_t - at_t) / (total_t ** 2 * (total_t + 1))).sqrt() # [K] exact.
172
+ spread_s = (at_s * (total_s - at_s) / (total_s ** 2 * (total_s + 1))).sqrt() # [K] exact.
173
+ talk(f"alpha_t = 0.5 c_t {concentration(0.5):.0f} mean "
174
+ f"{[round(float(v), 3) for v in belief_mean(0.5, 0)]} parameters "
175
+ f"{[round(float(v), 0) for v in at_t]} std on A {float(spread_t[0]):.3f}")
176
+ talk(f"alpha_s = 0.8 c_s {concentration(0.8):.0f} mean "
177
+ f"{[round(float(v), 2) for v in belief_mean(0.8, 0)]} parameters "
178
+ f"{[round(float(v), 0) for v in at_s]}")
179
+
180
+ # Section 03, the increment, the thinning weight, and the mean the mixture lands on.
181
+ step = increment(0.5, 0.8, 0) # [K] all of the increase sits on the clean coordinate.
182
+ first, second = thinning_law(0.5, 0.8) # Beta(8, 12), whose mean is 0.4.
183
+ mean_weight = first / (first + second) # c_t / c_s, the average share the old belief keeps.
184
+ mixed = mean_weight * belief_mean(0.5, 0) + (1.0 - mean_weight) * torch.eye(DNA_SIZE)[0] # [K].
185
+ talk(f"increment {[round(float(v), 0) for v in step]} W ~ Beta({first:.0f}, "
186
+ f"{second:.0f}) mean {mean_weight:.1f} mixture mean "
187
+ f"{[round(float(v), 2) for v in mixed]}")
188
+
189
+ # The same mixture by simulation, against the exact standard deviations of Dir(17, 1, 1, 1).
190
+ thinned = torch.distributions.Dirichlet(at_t).sample((args.mixture_draws,)) # [N, K] draw V.
191
+ weight = torch.distributions.Beta( # [N] the Beta draw of the representation.
192
+ torch.tensor(first), torch.tensor(second)).sample((args.mixture_draws,))
193
+ drawn = weight[:, None] * thinned + (1.0 - weight[:, None]) * torch.eye(DNA_SIZE)[0] # [N, K].
194
+ talk(f"mixture check {args.mixture_draws} draws std "
195
+ f"{[round(float(v), 3) for v in drawn.std(0)]} against the exact "
196
+ f"{[round(float(v), 3) for v in spread_s]}")
197
+
198
+ # Train the denoiser on the shared synthetic DNA. Reseeding here keeps the training batches
199
+ # independent of how many draws the check above consumed.
200
+ set_seed(args.seed)
201
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
202
+ train_tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
203
+ valid_tokens = splits["valid_tokens"].to(device) # [M, L] held out for the loss below.
204
+ model = BeliefDenoiser(args.length, width=args.width).to(device)
205
+ talk(f"device={device} parameters={count_parameters(model):,} "
206
+ f"train={len(train_tokens)} length={args.length}")
207
+
208
+ def step_loss(step_index: int) -> torch.Tensor:
209
+ """One optimizer step draws its own batch of clean sequences."""
210
+ rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device)
211
+ return loss(model, train_tokens[rows]) # [B, L] clean sequences into one scalar.
212
+
213
+ losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
214
+ with torch.no_grad():
215
+ held_out = sum(float(loss(model, valid_tokens)) for _ in range(4)) / 4.0
216
+
217
+ # Generate, then measure the structure the data was built with.
218
+ torch.manual_seed(args.seed + 1) # Fix the Beta and categorical draws, so the samples repeat.
219
+ samples = sample(model, args.samples, args.length, args.sample_steps) # [B, L] over ACGT.
220
+ report = {
221
+ "c_t at alpha_t = 0.5": float(concentration(0.5)),
222
+ "Dirichlet parameter on A at 0.5": float(at_t[0]),
223
+ "standard deviation on A at 0.5": float(spread_t[0]),
224
+ "Dirichlet parameter on A at 0.8": float(at_s[0]),
225
+ "increment on A": float(step[0]),
226
+ "mean thinning weight": mean_weight,
227
+ "simulated standard deviation on A": float(drawn.std(0)[0]),
228
+ "exact standard deviation on A": float(spread_s[0]),
229
+ "training loss, last 50": sum(losses[-50:]) / len(losses[-50:]),
230
+ "validation loss, nats/seq": held_out,
231
+ "loss, nats per token": held_out / args.length,
232
+ "GC fraction": gc_fraction(samples),
233
+ "GC fraction in the data": gc_fraction(train_tokens),
234
+ "motif fraction": motif_fraction(samples),
235
+ "motif fraction in the data": motif_fraction(train_tokens),
236
+ "distinct samples": unique_fraction(samples),
237
+ "letter entropy, nats": token_entropy(samples, DNA_SIZE),
238
+ }
239
+ print_report("simplex: beliefs in place of tokens", report, decode_dna(samples))
240
+ save_run(args.out, model=model, config=vars(args), report=report,
241
+ losses=losses, samples=decode_dna(samples))
242
+
243
+
244
+ # %% 08. Run the script
245
+ if __name__ == "__main__": # Execute when launched directly.
246
+ main() # Work the Dirichlet numbers, train the belief denoiser, and generate.
lecture_4/uniform.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Uniform replacement on DNA, the member of the pi family with no mask.
2
+
3
+ Corruption redraws a base from the uniform distribution over ACGT, so a noisy base may be the
4
+ original or a replacement, and no marker of the damage appears in the sequence. Every pair of
5
+ bases is then joined by a reverse rate, the loss keeps the staying term that masking cancelled, and
6
+ a sampler can revise a base it has already written.
7
+
8
+ Chapter 4, "A rate matrix with one free distribution", "The posterior average of conditional reverse
9
+ rates" and "Uniform replacement and the rates it produces". Time runs from t = 0 clean to t = 1
10
+ noise, the survival probability is alpha_t = exp(-beta t), and the rates R_t(x, y) act on
11
+ probability rows, p_dot = p R. Trained on the synthetic DNA of cis6270.data. Prints the worked
12
+ posterior average of 2, the worked rate error, and the sample statistics.
13
+
14
+ python lecture_4/uniform.py # train and generate
15
+ python lecture_4/uniform.py --steps 20 # a few seconds, for a first look
16
+ """
17
+
18
+ # %% 01. Imports and configuration
19
+ import math # The worked rate errors are logarithms.
20
+
21
+ import torch # Tensors, gradients and the categorical draws.
22
+
23
+ from cis6270.data import DNA_SIZE, decode_dna, dna_splits # ACGT, K = 4 and the shared sequences.
24
+ from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
25
+ from cis6270.nets import SequenceDenoiser, count_parameters # The denoiser d_theta(. | z, t).
26
+ from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
27
+
28
+ BETA = 4.0 # The constant noise rate of the chapter's worked example, so alpha_t = exp(-4t).
29
+ TIME_FLOOR = 2e-2 # The reverse rates grow like 1/(1 - alpha_t), so sampling stops just above zero.
30
+
31
+
32
+ # %% 02. The uniform kernel and the rates that produce it
33
+ #
34
+ # Under R_pi = 1 pi^T - I with pi uniform, every pair of distinct bases is joined at rate beta/K,
35
+ # and the matrix exponential is the kernel q_t(x | x_0) = alpha_t delta(x, x_0) + (1 - alpha_t)/K.
36
+ def survival(t: torch.Tensor | float) -> torch.Tensor | float:
37
+ """alpha_t = exp(-tau(t)) for the constant schedule beta(t) = BETA, so tau(t) = BETA t."""
38
+ return torch.exp(-BETA * t) if torch.is_tensor(t) else math.exp(-BETA * t)
39
+
40
+
41
+ def kernel(alpha: float | torch.Tensor) -> torch.Tensor:
42
+ """Q(alpha) over (A, C, G, T), whose (x_0, x) entry is q_t(x | x_0). Returns [K, K]."""
43
+ uniform = (1.0 - alpha) / DNA_SIZE # The share of the redrawn mass that lands on each base.
44
+ return alpha * torch.eye(DNA_SIZE, dtype=torch.float64) + uniform # [K, K] rows sum to one.
45
+
46
+
47
+ def corrupt(x0: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
48
+ """Redraw each position from the uniform distribution with probability 1 - alpha_t."""
49
+ keep = torch.rand(x0.shape, device=x0.device) < survival(t) # [B, L] survival of the original.
50
+ fresh = torch.randint(0, DNA_SIZE, x0.shape, device=x0.device) # [B, L] a draw from pi.
51
+ return torch.where(keep, x0, fresh) # [B, L] nothing marks which positions were redrawn.
52
+
53
+
54
+ # %% 03. The conditional reverse rates and their three cases
55
+ #
56
+ # Dividing the conditional reverse rate by the noise rate beta/K leaves one factor, which is
57
+ # 1 + K alpha_t/(1 - alpha_t) toward the clean token, its reciprocal away from it, and one between
58
+ # two tokens that are both wrong.
59
+ def clean_factor(alpha: float) -> float:
60
+ """1 + K alpha_t / (1 - alpha_t), the factor 5 of the worked example at alpha_t = 1/2."""
61
+ return 1.0 + DNA_SIZE * alpha / (1.0 - alpha) # The ratio q_t(x_0 | x_0) / q_t(x | x_0).
62
+
63
+
64
+ def conditional_rates(alpha: float, current: int, clean: int) -> torch.Tensor:
65
+ """R_hat_t(c, a | x_0) for every destination a, in units where the forward rate is one."""
66
+ Q = kernel(alpha) # [K, K] the forward kernel at this survival probability.
67
+ return Q[clean] / Q[clean, current] # [K] q_t(a | x_0) divided by q_t(c | x_0).
68
+
69
+
70
+ # %% 04. A worked posterior average on one base
71
+ #
72
+ # With a clean base A at probability 0.75 and C at 0.25, an observed C at alpha_t = 1/2 has
73
+ # posterior 0.375 and 0.625, and the two conditional rates 5 and 0.2 average to the marginal rate.
74
+ def posterior_over_clean(alpha: float, observed: int, prior: torch.Tensor) -> torch.Tensor:
75
+ """p(x_0 = b | X_t = observed), Bayes' rule with the forward kernel as the likelihood."""
76
+ weights = prior * kernel(alpha)[:, observed] # [K] prior times q_t(observed | b).
77
+ return weights / weights.sum() # [K] normalized over the candidate clean bases.
78
+
79
+
80
+ def marginal_reverse_rate(alpha: float, observed: int, destination: int,
81
+ prior: torch.Tensor) -> float:
82
+ """The posterior average of the conditional reverse rates, which is the marginal reverse rate."""
83
+ posterior = posterior_over_clean(alpha, observed, prior) # [K] the weights of the average.
84
+ targets = kernel(alpha)[:, destination] / kernel(alpha)[:, observed] # [K] one target per clean b.
85
+ return float(posterior @ targets) # The two together give the rate the sampler uses.
86
+
87
+
88
+ # %% 05. The rate error and the staying term
89
+ #
90
+ # l(A, B) = A log(A/B) - A + B is the Bregman divergence the path KL divergence produces. The term
91
+ # -A + B comes from the probability of staying put, and under uniform noise it does not cancel.
92
+ def rate_error(target: torch.Tensor, model_rate: torch.Tensor) -> torch.Tensor:
93
+ """Elementwise l(A, B), nonnegative and zero exactly at B = A."""
94
+ logarithmic = torch.where(target > 0, target * (target.clamp(min=1e-12) / model_rate).log(),
95
+ torch.zeros_like(target)) # A log(A/B), with l(0, B) = B.
96
+ return logarithmic - target + model_rate # The staying term is the -A + B part.
97
+
98
+
99
+ def ratio_table(current: torch.Tensor, alpha: torch.Tensor) -> torch.Tensor:
100
+ """q_t(a | b) / q_t(c | b) for every clean base b and destination a. Returns [B, L, K, K]."""
101
+ uniform = (1.0 - alpha) / DNA_SIZE # [B, 1] the redrawn share at this noise level.
102
+ Q = (alpha[..., None, None] * torch.eye(DNA_SIZE, device=current.device)
103
+ + uniform[..., None, None]) # [B, 1, K, K] the kernel q_t(a | b) at each sequence's time.
104
+ denominator = Q.expand(-1, current.shape[1], -1, -1).gather(
105
+ 3, current[:, :, None, None].expand(-1, -1, DNA_SIZE, 1)) # [B, L, K, 1] q_t(c | b).
106
+ return Q / denominator # [B, L, K, K] the only quantity the rates need from the kernel.
107
+
108
+
109
+ def model_rates(denoiser: torch.Tensor, ratios: torch.Tensor) -> torch.Tensor:
110
+ """R_hat^theta_t(c, a) = (beta/K) sum_b d_theta(b) q_t(a | b) / q_t(c | b). Returns [B, L, K]."""
111
+ averaged = torch.einsum("nlb,nlba->nla", denoiser, ratios) # [B, L, K] averaged over clean bases.
112
+ return (BETA / DNA_SIZE) * averaged # [B, L, K] the forward rate times the posterior average.
113
+
114
+
115
+ def conditional_targets(x0: torch.Tensor, ratios: torch.Tensor) -> torch.Tensor:
116
+ """R_hat_t(c, a | x_0), the same formula with the posterior collapsed onto the clean token."""
117
+ rows = x0[:, :, None, None].expand(-1, -1, 1, DNA_SIZE) # [B, L, 1, K] select the row b = x_0.
118
+ return (BETA / DNA_SIZE) * ratios.gather(2, rows).squeeze(2) # [B, L, K] one target per jump.
119
+
120
+
121
+ def rate_loss(model: torch.nn.Module, x0: torch.Tensor) -> torch.Tensor:
122
+ """One Monte Carlo sample of L_cond, summed over destinations and positions."""
123
+ t = torch.rand(x0.shape[0], 1, device=x0.device).clamp(min=TIME_FLOOR) # [B, 1] one time each.
124
+ alpha = survival(t) # [B, 1] the survival probability at that time.
125
+ zt = corrupt(x0, t) # [B, L] the noisy sequence, with no marker of which positions moved.
126
+ ratios = ratio_table(zt, alpha) # [B, L, K, K] the kernel ratios at this noise level.
127
+ denoiser = model(zt, t[:, 0]).softmax(-1) # [B, L, K] the posterior over clean tokens.
128
+ predicted = model_rates(denoiser, ratios).clamp(min=1e-8) # [B, L, K] nonnegative by construction.
129
+ target = conditional_targets(x0, ratios) # [B, L, K] the rate attached to this clean sequence.
130
+ staying = torch.nn.functional.one_hot(zt, DNA_SIZE).bool() # [B, L, K] the destination c itself.
131
+ return rate_error(target, predicted).masked_fill(staying, 0.0).sum((1, 2)).mean()
132
+
133
+
134
+ # %% 06. Sampling by tau-leaping
135
+ #
136
+ # Over a step of length h a position moves to a with probability h times its reverse rate, and the
137
+ # remaining probability keeps it where it is. The step is valid while h times the total rate is one.
138
+ @torch.no_grad() # Generation needs no gradient tracking.
139
+ def sample(model: torch.nn.Module, batch: int, length: int, steps: int = 64) -> torch.Tensor:
140
+ """Generate from uniformly random tokens down to clean DNA. Returns [B, L] over ACGT."""
141
+ device = next(model.parameters()).device
142
+ z = torch.randint(0, DNA_SIZE, (batch, length), device=device) # [B, L] a draw from pi.
143
+ ts = torch.linspace(1.0, TIME_FLOOR, steps + 1, device=device) # The reverse grid.
144
+ for t, s in zip(ts[:-1], ts[1:]):
145
+ time_row = t.expand(batch, 1) # [B, 1] the shared time of this step.
146
+ denoiser = model(z, time_row[:, 0]).softmax(-1) # [B, L, K] the clean-token prediction.
147
+ rates = model_rates(denoiser, ratio_table(z, survival(time_row))) # [B, L, K] per destination.
148
+ jump = (t - s) * rates # [B, L, K] the tau-leaping probabilities of this step.
149
+ jump.scatter_(2, z[:, :, None], 0.0) # A jump to the current base is not a jump.
150
+ total = jump.sum(-1, keepdim=True).clamp(min=1.0) # Rescale when the step would exceed one.
151
+ jump = jump / total # [B, L, K] now a sub-probability vector at every position.
152
+ stay = (1.0 - jump.sum(-1, keepdim=True)).clamp(min=0.0) # [B, L, 1] the chance of staying.
153
+ choice = torch.distributions.Categorical(
154
+ probs=torch.cat([jump, stay], dim=-1)).sample() # [B, L] destination, or K for staying.
155
+ z = torch.where(choice == DNA_SIZE, z, choice.clamp(max=DNA_SIZE - 1)) # [B, L].
156
+ final = model(z, ts[-1].expand(batch)).softmax(-1) # The exact posterior step down to t = 0.
157
+ return torch.distributions.Categorical(probs=final).sample() # [B, L] clean DNA.
158
+
159
+
160
+ # %% 07. The report
161
+ def main() -> None:
162
+ parser = common_parser(__doc__.split("\n")[0])
163
+ # The rate loss needs a longer budget than the worked numbers do, so the default trains for
164
+ # 1000 steps and measures on 64 sequences, which keeps the run inside about a minute.
165
+ parser.set_defaults(steps=1000, sample_steps=64, samples=64)
166
+ args = parser.parse_args()
167
+ set_seed(args.seed)
168
+ device = pick_device(args.device)
169
+ talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
170
+
171
+ # Sections 03 and 04, the three cases and the worked posterior average on one base.
172
+ prior = torch.tensor([0.75, 0.25, 0.0, 0.0], dtype=torch.float64) # Clean A at 0.75, C at 0.25.
173
+ posterior = posterior_over_clean(0.5, 1, prior) # [4] after observing a C at alpha_t = 1/2.
174
+ targets = conditional_rates(0.5, current=1, clean=0) # [4] the rates attached to a clean A.
175
+ average = marginal_reverse_rate(0.5, 1, 0, prior) # The posterior average, which comes out 2.
176
+ marginal = prior @ kernel(0.5) # [4] p_t over the four bases, which is (0.5, 0.25, 0.125, 0.125).
177
+ talk(f"kernel at alpha=0.5 clean {float(kernel(0.5)[0, 0]):.3f} other "
178
+ f"{float(kernel(0.5)[0, 1]):.3f} clean factor {clean_factor(0.5):.0f}")
179
+ talk(f"posterior after C {[round(float(v), 3) for v in posterior]} conditional rates "
180
+ f"{float(targets[0]):.0f} under a clean A and "
181
+ f"{float(conditional_rates(0.5, 1, 1)[0]):.1f} under a clean C")
182
+ talk(f"posterior average {average:.1f} marginals {[round(float(v), 3) for v in marginal]} "
183
+ f"ratio {float(marginal[0] / marginal[1]):.1f} to G and T "
184
+ f"{marginal_reverse_rate(0.5, 1, 2, prior):.1f}")
185
+
186
+ # Section 05, the rate error on one base, where the staying term keeps the sum from collapsing.
187
+ worked_denoiser = torch.tensor([[[0.5, 0.3, 0.1, 0.1]]], dtype=torch.float64) # [1, 1, 4].
188
+ worked_current = torch.tensor([[1]]) # The current base is C.
189
+ worked_alpha = torch.tensor([[0.5]], dtype=torch.float64) # alpha_t = 1/2.
190
+ worked_ratios = ratio_table(worked_current, worked_alpha) # [1, 1, K, K] at alpha_t = 1/2.
191
+ predicted = model_rates(worked_denoiser, worked_ratios)[0, 0] / (BETA / DNA_SIZE)
192
+ target = conditional_rates(0.5, current=1, clean=0) # The clean base is A.
193
+ error = float(rate_error(target[[0, 2, 3]], predicted[[0, 2, 3]]).sum()) # Skip the current base.
194
+ talk(f"model rates from d {[round(float(v), 2) for v in predicted]} targets "
195
+ f"{[round(float(v), 1) for v in target]} rate error {error:.3f}")
196
+
197
+ # Train the denoiser against the conditional rate targets.
198
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
199
+ train_tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
200
+ valid_tokens = splits["valid_tokens"].to(device) # [M, L] held out for the loss below.
201
+ model = SequenceDenoiser(DNA_SIZE, args.length, width=args.width).to(device)
202
+ talk(f"device={device} parameters={count_parameters(model):,} train={len(train_tokens)}")
203
+
204
+ def step_loss(step: int) -> torch.Tensor:
205
+ rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device)
206
+ return rate_loss(model, train_tokens[rows]) # One clean batch into one scalar.
207
+
208
+ losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
209
+ with torch.no_grad():
210
+ held_out = sum(float(rate_loss(model, valid_tokens)) for _ in range(4)) / 4.0
211
+
212
+ torch.manual_seed(args.seed + 1) # Fix the jump draws, so the samples repeat.
213
+ samples = sample(model, args.samples, args.length, args.sample_steps) # [B, L] over ACGT.
214
+ report = {
215
+ "q_t(x_0 | x_0) at alpha=0.5": float(kernel(0.5)[0, 0]),
216
+ "clean factor at alpha=0.5": clean_factor(0.5),
217
+ "posterior on A after a C": float(posterior[0]),
218
+ "conditional rate under A": float(targets[0]),
219
+ "conditional rate under C": float(conditional_rates(0.5, 1, 1)[0]),
220
+ "posterior average rate": average,
221
+ "rate from the marginals": float(marginal[0] / marginal[1]),
222
+ "worked model rate to A": float(predicted[0]),
223
+ "worked rate error": error,
224
+ "training rate loss, last 50": sum(losses[-50:]) / len(losses[-50:]),
225
+ "validation rate loss": held_out,
226
+ "GC fraction": gc_fraction(samples),
227
+ "GC fraction in the data": gc_fraction(train_tokens),
228
+ "motif fraction": motif_fraction(samples),
229
+ "motif fraction in the data": motif_fraction(train_tokens),
230
+ "distinct samples": unique_fraction(samples),
231
+ "letter entropy, nats": token_entropy(samples, DNA_SIZE),
232
+ }
233
+ print_report("uniform: replacement diffusion on DNA", report, decode_dna(samples))
234
+ save_run(args.out, model=model, config=vars(args), report=report,
235
+ losses=losses, samples=decode_dna(samples))
236
+
237
+
238
+ # %% 08. Run the script
239
+ if __name__ == "__main__": # Execute when launched directly.
240
+ main() # Work the posterior average, train against the rate loss, and generate.
lecture_5/README.md ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Lecture 5: Discrete Flow Matching (Chapter 5)
2
+
3
+ Chapter 4 fixed a corruption process and solved for its reverse. This lecture reverses that order.
4
+ We pick the probability path first, write down a velocity that generates it, and read the sampler
5
+ off the velocity. On tokens the velocity is a rate matrix read from the destination's side, the
6
+ master equation is the condition under which it generates a path, and the mixture path
7
+ `(1 - kappa_t) delta_{x_0} + kappa_t delta_{x_1}` at each position has a conditional velocity that a
8
+ posterior average turns into the schedule factor times a denoiser. Cross-entropy on the data token
9
+ recovers that denoiser. Everything after that is a choice made inside the same construction: a
10
+ schedule, a corrector, a kinetic-optimal path, edits that change the length, a relaxation of the
11
+ token into a point of the simplex or of a sphere, a guidance tilt, or a different coupling.
12
+
13
+ Two conventions hold in every file and are stated at the top of each. Time runs from the source at
14
+ `t = 0` to the data at `t = 1`, so `kappa_t` here equals the survival probability `alpha_{1-t}` of
15
+ Chapter 4. The probability velocity `u_t(y, x)` is the same number Chapter 4 writes as the rate
16
+ `R_t(x, y)`, with the two arguments exchanged.
17
+
18
+ ## The files
19
+
20
+ Everything runs on the synthetic DNA of `cis6270.data`: 512 sequences of length 16 over A, C, G, T,
21
+ with a GC-rich background and the motif ACGT planted in three quarters of them at one of three
22
+ positions. Those sequences have GC fraction 0.577, motif fraction 0.777 and token entropy 1.374 of
23
+ `log 4 = 1.386`, which are the numbers a trained sampler is measured against.
24
+
25
+ | File | Command | What it does |
26
+ | --- | --- | --- |
27
+ | `dfm.py` | `python lecture_5/dfm.py` | The coupling, the mixture path, the conditional velocity, the posterior average, the cross-entropy loss and the two-part Euler rule. Trains a denoiser and generates DNA from either source, `--source uniform` or `--source mask`. |
28
+ | `velocity.py` | `python lecture_5/velocity.py` | The mathematics with nothing learned: a jump rate written as a velocity, the two rules, the master equation, the worked DNA velocity, survival and hazard, and the continuous and discrete velocities side by side. |
29
+ | `corrector.py` | `python lecture_5/corrector.py` | Velocities with zero net flux, the three-state example, the corrector for the mask source, the jump counts at three corrector strengths, and the parallel-step error. |
30
+ | `kinetic_optimal.py` | `python lecture_5/kinetic_optimal.py` | The flux and its energy, the velocity of least energy, the kinetic optimality of the mixture path, the transition distance on DNA, the worked metric path, and the energy of the linear and geodesic schedules. |
31
+ | `edit_flows.py` | `python lecture_5/edit_flows.py` | Insertions, deletions and substitutions; alignments and the column-wise path; the posterior over two alignments; the rate loss; one sampler step; and the length statistics of a run on DNA pairs of different lengths. |
32
+ | `dirichlet.py` | `python lecture_5/dirichlet.py` | The Dirichlet family as a conditional path, the speed from the flux across a cut, the two-letter closed form, the worked speeds on a DNA base, and the posterior average. Trains a denoiser on the simplex and integrates to `t = 8`. |
33
+ | `fisher.py` | `python lecture_5/fisher.py` | The Fisher information as a metric, the square-root map to the sphere, geodesics, the tangent projection and the regression loss. Trains a velocity field and samples with exponential-map steps. |
34
+ | `gumbel.py` | `python lecture_5/gumbel.py` | The Gumbel-max trick, the cooling temperature, the annealed path, the replicator equation, the inference-time velocity, and training against the marginal velocity. |
35
+ | `guidance.py` | `python lecture_5/guidance.py` | Guidance as a tilt of the rates, the h-transform, the reachable set, the guidance strength, the classifier-free form, the Taylor expansion of the log ratio, and the guided sampler. Steers toward the GC label with `--gamma`. |
36
+ | `multi_objective.py` | `python lecture_5/multi_objective.py` | The Pareto front, MOG-DFM's rank, direction and cone with the controller that widens it, and pCoMole's worst-objective utility with a rollout of the base flow on DNA. |
37
+ | `rectify.py` | `python lecture_5/rectify.py` | Total correlation on two bases, ReDi's recoupling rounds run on the exact sampler, and AReUReDi's Tchebycheff tilt with its Metropolis acceptance step. |
38
+
39
+ Every file takes the shared flags of `cis6270.runner`. `--steps 20 --quiet` runs each one in a few
40
+ seconds, and `--steps` controls the training steps in the five files that train, the grid in the
41
+ five that integrate, and the number of rollouts in `multi_objective.py`.
42
+
43
+ ## The numbers a default run produces
44
+
45
+ Seed 0, one core. The five files that train take under half a minute each, and the other six take
46
+ one to seven seconds. The worked numbers are exact and repeat on any machine; the trained numbers
47
+ move a little with the hardware's floating point.
48
+
49
+ | File | Headline numbers |
50
+ | --- | --- |
51
+ | `dfm.py` | loss 0.876 nats, GC fraction 0.578, motif fraction 0.672, token entropy 1.374 of `log 4 = 1.386` |
52
+ | `velocity.py` | `p_dot = (-0.1, -0.2, 0.7, -0.4)`, Euler step `(0.09, 0.18, 0.37, 0.36)`, hazard 2 at `t = 0.5` and 10 at `t = 0.9`, Euler error 0.0002 against the closed form |
53
+ | `corrector.py` | masked fraction 0.500 at `t = 0.5` for every `eta`, jumps per position 1.000, 2.991 and 10.934 at `eta = 0, 2, 10`, pair survival 0.133 measured against the predicted 0.135 at `eta = 2` |
54
+ | `kinetic_optimal.py` | rate out of C 5.780, `p_dot = (-0.3997, -0.4774, 1.3546, -0.4774)` from the velocity and from the path, energy 27.63 and 41.45 for the linear schedule, `pi^2 = 9.8696` for the geodesic one |
55
+ | `edit_flows.py` | averaged edit rates 0.667, 1.333 and 0.667 for a total of 2.667, example loss 3.446, untouched 0.8452 against the first-order 0.8350, DNA length 13.945 at `t = 0.5` against the predicted 13.941 |
56
+ | `dirichlet.py` | speeds 0.232, 0.199, 0.159, 0.104 at `t = 1`, continuity residual `2.5e-08`, loss 1.027 nats, GC fraction 0.532, motif fraction 0.547 |
57
+ | `fisher.py` | Fisher-Rao distance 1.485 against the Euclidean 0.141 and 0.013 on the two moves, arc 0.3713 from the midpoint, loss 0.278, GC fraction 0.576, motif fraction 0.203 |
58
+ | `gumbel.py` | path `(0.0528, 0.0214, 0.8781, 0.0477)` at `t = 1`, replicator velocity `(-0.136, -0.217, 0.500, -0.147)`, loss 0.672 nats, GC fraction 0.572, motif fraction 0.516 |
59
+ | `guidance.py` | guided destination `(0.029, 0.203, 0.478, 0.290)`, exit rate 2.50 at the exact `h`, GC fraction 0.531 unguided, 0.657 at `gamma = 2` and 0.698 at `gamma = 5` |
60
+ | `multi_objective.py` | combined score `(-1.511, 1.541, 2.297, -2.327)`, cone angles 47.7, 37.9 and 107.7 degrees, selection `(0.371, 0.629, 0)` after the chapter's rollouts, and on DNA a base selection of `(0.417, 0.583)` that the rollout value turns into `(0.962, 0.038)` |
61
+ | `rectify.py` | total correlation 1.386, 1.101, 0.843, 0.696 and 0.616 over rounds 0 to 4 against the limit 0.520; one-step success 25.0%, 40.3%, 52.5%, 58.0% and 60.4% against the limit 62.5% |
62
+
63
+ Four thousand training steps, about two minutes a file, buy a sampler that reaches the planted
64
+ motif more often: `dfm.py` goes from 0.672 to 0.734 against the data's 0.777, and `fisher.py` from
65
+ 0.203 to 0.453. Nothing else moves much.
66
+
67
+ `tests/test_lecture_5.py` asserts the chapter's worked numbers against these files, including the
68
+ posterior 0.625 and 0.375 at a masked position, the entropy 0.6616 that bounds the loss there, the
69
+ velocity `(0.2, 1.2, -1.6, 0.2)` out of G, the metric-path rates 0.898 and 4.882 out of C, the
70
+ Dirichlet speeds, the Fisher-Rao arc, the Gumbel-softmax path at four times, the guided destination
71
+ distribution, the Pareto front, and the ReDi rounds.
72
+
73
+ ## What is simplified
74
+
75
+ - The corrector simulation uses 40,000 positions where the chapter uses 200,000. Pass
76
+ `--positions 200000` to reproduce the chapter's figures exactly.
77
+ - The parallel-step section measures the survival probability of a jointly revealed pair, which is
78
+ the quantity the chapter computes in closed form. At a fine grid the end-to-end error of the
79
+ corrector is not lower than at `eta = 0`, because the admissible step shrinks with `eta`.
80
+ - `edit_flows.py` runs the column-wise path on one sampled alignment per pair, which the chapter's
81
+ marginalization theorem permits, and trains nothing. The edit intensities of the sampler section
82
+ are the chapter's own numbers, not a network's.
83
+ - `dirichlet.py` evaluates the derivative of the regularized incomplete beta function by a central
84
+ difference on `scipy.special.betainc`, in double precision whatever the ambient dtype, since the
85
+ two values agree to six decimals. The continuity check differentiates the flux with a five-point
86
+ stencil.
87
+ - `rectify.py` runs ReDi with the exact marginal velocity of the current coupling on a
88
+ two-position state space, so no network is trained. The AReUReDi section works one Metropolis
89
+ iteration, not a full refinement chain.
90
+ - `guidance.py` trains the denoiser and the property model under one optimizer, and the property is
91
+ the GC label of `cis6270.data`, not a biological measurement.
92
+ - `multi_objective.py` trains nothing. Its two DNA objectives are the GC fraction and the AT
93
+ fraction of a finished sequence, its feasible set is the sequences carrying the planted motif,
94
+ and the base flow a rollout continues is the mask-source sampler with the data's letter frequency
95
+ as its denoiser.
96
+ - The relaxed paths replace the token lookup of `cis6270.nets.SequenceDenoiser` with a linear map on
97
+ the relaxed token, and leave the rest of the network untouched.
lecture_5/corrector.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Correctors: extra motion that leaves the probability path where it is.
2
+
3
+ The master equation is linear in the velocity, so a second velocity with no net flux into any state
4
+ can be added to the first without moving the marginals. Detailed balance is the easy way to arrange
5
+ that, and the mixture path already contains such a term, the difference between the forward velocity
6
+ and the same path run backward. Adding it with weight eta makes a position unmask more often and
7
+ remask in between, which gives the sampler more chances to replace a token the rest of the sequence
8
+ has ruled out.
9
+
10
+ Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
11
+ u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
12
+
13
+ Chapter 5, Sections 5.13 and 5.14. The denoiser of the long simulation is the letter frequency of the
14
+ synthetic DNA of ``cis6270.data``. Prints the zero-flux check, the per-step unmasking table, the jump
15
+ counts at three corrector strengths, the parallel-step error at 64 steps, and the chance a jointly
16
+ revealed pair survives to t = 1 untouched.
17
+
18
+ python lecture_5/corrector.py # 600 Euler steps on 40,000 positions
19
+ python lecture_5/corrector.py --eta 10 # one corrector strength in the step table
20
+ python lecture_5/corrector.py --steps 20 --quiet # a coarse grid, visibly biased jump counts
21
+ """
22
+
23
+ # %% 01. Imports and configuration
24
+ import math
25
+
26
+ import torch
27
+
28
+ from cis6270.data import DNA, DNA_SIZE, dna_splits
29
+ from cis6270.runner import common_parser, print_report, save_run, set_seed
30
+
31
+ EXACT = torch.double # The worked arithmetic keeps full precision whatever the default dtype.
32
+ MASK = DNA_SIZE # Index 4 is the mask symbol m, outside the four-letter alphabet.
33
+
34
+
35
+ # %% 02. Velocities that move samples without moving the path
36
+ #
37
+ # A velocity v_t whose net flux into every state is zero can be added to a velocity that generates
38
+ # p_t, and the sum generates the same path. Detailed balance, equal flux in both directions along
39
+ # every edge, is sufficient. Reversing every term turns the net flux into p_t(y) times a column of
40
+ # rates, and those rates sum to zero.
41
+
42
+
43
+ def net_flux(v, p):
44
+ """sum_x v_t(y, x) p_t(x) at every state y. v: [K, K], p: [K] -> [K]."""
45
+ return v @ p # Zero at every entry when v moves no probability.
46
+
47
+
48
+ def detailed_balance_velocity(p, chosen):
49
+ """Build v_t from chosen forward rates, with each reverse rate fixed by detailed balance.
50
+
51
+ ``chosen`` maps an edge (x, y) to the rate v_t(y, x). The reverse rate is then
52
+ v_t(x, y) = v_t(y, x) p_t(x) / p_t(y), which equalizes the flux along that edge.
53
+ """
54
+ v = torch.zeros(len(p), len(p), dtype=p.dtype) # [K, K], destination indexing the row.
55
+ for (x, y), rate in chosen.items():
56
+ v[y, x] = rate # The rate we pick, out of x toward y.
57
+ v[x, y] = rate * p[x] / p[y] # The reverse rate detailed balance forces.
58
+ return v - torch.diag(v.sum(dim=0)) # Each column now sums to zero.
59
+
60
+
61
+ # %% 03. A zero-flux velocity on three states
62
+ #
63
+ # On the states A, C, G with p_t = (0.2, 0.3, 0.5) we can pick the rate 3 from A to C and the rate 1
64
+ # from A to G, and detailed balance returns 2 and 0.4. Adding this to a velocity that sends every
65
+ # state to G at rate 1 raises the total rate of leaving A from 1 to 5 while p_dot stays put.
66
+
67
+
68
+ def three_state_example():
69
+ """Section 5.13.2, returning the two velocities, the path and the two rates of change."""
70
+ p = torch.tensor([0.2, 0.3, 0.5], dtype=EXACT) # [3] over A, C, G.
71
+ v = detailed_balance_velocity(p, {(0, 1): 3.0, (0, 2): 1.0}) # A to C at 3, A to G at 1.
72
+ u = torch.zeros(3, 3, dtype=EXACT) # The velocity that does move the path.
73
+ for x in range(2):
74
+ u[2, x], u[x, x] = 1.0, -1.0 # A and C jump to G at rate 1; G never leaves.
75
+ return p, u, v, net_flux(u, p), net_flux(u + v, p) # The last two agree.
76
+
77
+
78
+ # %% 04. The corrector for the mask source
79
+ #
80
+ # With the linear schedule, the weights alpha_t = 1 + eta t and beta_t = eta t, and the mask source,
81
+ # the backward velocity is the point mass on the mask and the combination collapses to two brackets.
82
+ # A masked position unmasks at total rate (1 + eta t)/(1 - t), split over tokens by the denoiser, and
83
+ # a visible position remasks at rate eta.
84
+
85
+
86
+ def corrector_velocity(p1, x_i, t, eta):
87
+ """bar_u^i_t(., x) over the five symbols A, C, G, T, m. p1: [4] -> [5]."""
88
+ current = torch.zeros(DNA_SIZE + 1, dtype=EXACT)
89
+ current[x_i] = 1.0 # delta on the token the position holds, which may be the mask.
90
+ denoiser = torch.cat([p1.to(EXACT), torch.zeros(1, dtype=EXACT)]) # The data token is never m.
91
+ to_mask = torch.zeros(DNA_SIZE + 1, dtype=EXACT)
92
+ to_mask[MASK] = 1.0 # delta_m, the destination of a remasking.
93
+ forward = (1.0 + eta * t) / (1.0 - t) * (denoiser - current) # The first bracket.
94
+ return forward + eta * (to_mask - current) # The second bracket, the circulation back to m.
95
+
96
+
97
+ def admissible_step(t, eta):
98
+ """The largest Euler step with a redraw probability at most one, h <= (1 - t)/(1 + eta t)."""
99
+ return (1.0 - t) / (1.0 + eta * t) # 0.1, 0.0357 and 0.01 at t = 0.9 for eta = 0, 2, 10.
100
+
101
+
102
+ # %% 05. Counting the jumps
103
+ #
104
+ # Writing M(t) for the probability that the position is masked, the two flows cancel so that
105
+ # M_dot(t) = -1 for every eta, and M(t) = 1 - t whatever the corrector strength. Integrating the
106
+ # unmasking rate against M and the remasking rate against 1 - M gives 1 + eta/2 unmaskings and eta/2
107
+ # remaskings, for 1 + eta jumps per position.
108
+
109
+
110
+ def masked_fraction_derivative(t, eta, masked=None):
111
+ """M_dot(t) = -M (1 + eta t)/(1 - t) + (1 - M) eta, which equals -1 at M = 1 - t."""
112
+ masked = 1.0 - t if masked is None else masked # The solution we expect.
113
+ return -masked * (1.0 + eta * t) / (1.0 - t) + (1.0 - masked) * eta
114
+
115
+
116
+ def simulate_jumps(p1, eta, positions, steps, generator=None):
117
+ """Run ``positions`` independent positions from the mask and count their jumps.
118
+
119
+ Returns the fraction still masked at t = 0.5 and the mean number of jumps per position.
120
+ """
121
+ state = torch.full((positions,), MASK, dtype=torch.long) # Every position masked at t = 0.
122
+ jumps = torch.zeros(positions, dtype=torch.int32) # One counter per position.
123
+ h, halfway = 1.0 / steps, None # The increment, and the masked fraction recorded at t = 0.5.
124
+ for k in range(steps):
125
+ t = k * h
126
+ masked = state == MASK # [P] which positions are still at the mask symbol.
127
+ if halfway is None and t >= 0.5:
128
+ halfway = float(masked.to(torch.get_default_dtype()).mean()) # M(0.5), which the chapter puts at 1 - t.
129
+ up = min(1.0, h * (1.0 + eta * t) / max(1.0 - t, 1e-12)) # Unmasking probability this step.
130
+ down = h * eta # Remasking probability this step.
131
+ coin = torch.rand(positions, generator=generator)
132
+ unmask = masked & (coin < up) # Masked positions that reveal a token.
133
+ remask = (~masked) & (coin < down) # Visible positions that return to the mask.
134
+ revealed = int(unmask.sum()) # Draw a token only where one is needed.
135
+ if revealed:
136
+ tokens = torch.multinomial(p1, revealed, replacement=True, generator=generator)
137
+ state = state.masked_scatter(unmask, tokens) # Split over tokens by the denoiser.
138
+ state = state.masked_fill(remask, MASK) # The circulation back to the mask symbol.
139
+ jumps += (unmask | remask).to(torch.int32) # Every reveal and remasking counts as a jump.
140
+ return halfway, float(jumps.double().mean()) # Near 1 - t = 0.5, and near 1 + eta.
141
+
142
+
143
+ # %% 06. The parallel-step error
144
+ #
145
+ # Let the data be uniform over AA and TT and the source be all masks. With both positions masked the
146
+ # denoiser at each is (0.5, 0.5), and a step that reveals both at once draws them independently, so
147
+ # half of those draws land outside the data. With eta > 0 a pair revealed jointly is remasked at rate
148
+ # 2 eta and redrawn with the other position visible, where the denoiser is a point mass.
149
+
150
+
151
+ def joint_reveal_rate(steps):
152
+ """The probability that the two reveal times land in the same cell of a uniform grid, h."""
153
+ return 1.0 / steps # Sum over cells of h squared, which is 1/64 at 64 steps.
154
+
155
+
156
+ def pair_survival(eta, tau=0.5):
157
+ """exp(-2 eta (1 - tau)), the chance a pair revealed at tau is untouched at t = 1."""
158
+ return math.exp(-2.0 * eta * (1.0 - tau)) # 0.135 at eta = 2 and 4.54e-5 at eta = 10.
159
+
160
+
161
+ def simulate_survival(eta, samples, steps, tau=0.5, generator=None):
162
+ """The fraction of pairs revealed at ``tau`` that neither position remasks before t = 1.
163
+
164
+ Each of the two positions returns to the mask at rate eta, so the first remasking of either
165
+ happens at rate 2 eta, and a pair that is remasked is redrawn with the other position visible.
166
+ """
167
+ remaining = max(1, int(steps * (1.0 - tau))) # Grid cells left between tau and t = 1.
168
+ h = (1.0 - tau) / remaining # The increment, matching the grid the sampler runs on.
169
+ untouched = torch.ones(samples, dtype=torch.bool) # Pairs no remasking has reached.
170
+ for _ in range(remaining):
171
+ coin = torch.rand(samples, 2, generator=generator) # One clock per position.
172
+ untouched &= ~(coin < h * eta).any(dim=1) # Either position remasking ends the survival.
173
+ return float(untouched.to(torch.get_default_dtype()).mean()) # Approaches exp(-2 eta (1 - tau)).
174
+
175
+
176
+ def simulate_pair(eta, samples, steps, generator=None):
177
+ """The AA-or-TT two-position problem under the corrector. Returns the fraction outside the data."""
178
+ state = torch.full((samples, 2), MASK, dtype=torch.long) # Both positions masked at t = 0.
179
+ h = 1.0 / steps
180
+ for k in range(steps):
181
+ t, last = k * h, k == steps - 1 # The last step reveals whatever is left.
182
+ masked = state == MASK # [S, 2] read before either update, so the two are simultaneous.
183
+ other = state.flip(1) # What the opposite position holds, which the denoiser reads.
184
+ guess = torch.randint(0, 2, state.shape, generator=generator) # The (0.5, 0.5) denoiser.
185
+ drawn = torch.where(other != MASK, other, guess) # A point mass once the partner is visible.
186
+ up = 1.0 if last else min(1.0, h * (1.0 + eta * t) / max(1.0 - t, 1e-12))
187
+ down = 0.0 if last else h * eta # No remasking on the step that ends the run.
188
+ reveal = masked & (torch.rand(state.shape, generator=generator) < up)
189
+ remask = (~masked) & (torch.rand(state.shape, generator=generator) < down)
190
+ state = torch.where(reveal, drawn, state) # Unmask, in parallel at both positions.
191
+ state = torch.where(remask, torch.full_like(state, MASK), state) # The circulation back to m.
192
+ return float((state[:, 0] != state[:, 1]).double().mean()) # Outside {AA, TT}.
193
+
194
+
195
+ # %% 07. The report
196
+ def main():
197
+ parser = common_parser(__doc__.split("\n")[0])
198
+ parser.add_argument("--eta", type=float, default=2.0, help="corrector strength in the step table")
199
+ parser.add_argument("--positions", type=int, default=40_000, help="positions in the simulation")
200
+ parser.add_argument("--pairs", type=int, default=20_000, help="sequences in the pair simulation")
201
+ args = parser.parse_args()
202
+ set_seed(args.seed)
203
+
204
+ p, u, v, with_u, with_both = three_state_example()
205
+ report = {
206
+ "zero_flux": [round(float(f), 6) for f in net_flux(v, p)], # Zero at A, C and G.
207
+ "p_dot_u": [round(float(f), 4) for f in with_u], # (-0.2, -0.3, 0.5).
208
+ "p_dot_u_plus_v": [round(float(f), 4) for f in with_both], # The same three numbers.
209
+ "rate_out_of_A_u": round(float(-u[0, 0]), 4), # 1 under the velocity alone.
210
+ "rate_out_of_A_both": round(float(-(u + v)[0, 0]), 4), # 5 once the corrector is added.
211
+ }
212
+
213
+ # Section 5.14.3: one masked position at t = 0.4 with the chapter's denoiser and h = 0.01.
214
+ p1 = torch.tensor([0.1, 0.6, 0.2, 0.1]) # [4] over A, C, G, T.
215
+ bar_u = corrector_velocity(p1, MASK, t=0.4, eta=args.eta) # [5] rates out of the mask.
216
+ seen = torch.zeros(DNA_SIZE)
217
+ seen[1] = 1.0 # At a visible position the true denoiser is the point mass on the visible token.
218
+ report["unmask_rate"] = round(float(-bar_u[MASK]), 4) # (1 + eta t)/(1 - t); 3.0 at eta = 2.
219
+ report["remask_rate"] = round(float(corrector_velocity(seen, 1, 0.4, args.eta)[MASK]), 4)
220
+ report["step_probabilities"] = [round(float(0.01 * r), 5) for r in bar_u[:DNA_SIZE]]
221
+ report["stay_masked"] = round(float(1.0 + 0.01 * bar_u[MASK]), 5) # 0.97 at eta = 2.
222
+ report["remask_probability"] = round(0.01 * args.eta, 4) # eta h, which is 0.02 at eta = 2.
223
+ report["masked_derivative"] = round(masked_fraction_derivative(0.4, args.eta), 6) # -1.
224
+ report["admissible_step_at_0.9"] = round(admissible_step(0.9, args.eta), 4) # 0.0357 at eta = 2.
225
+
226
+ # The long simulation, with the letter frequency of the synthetic DNA as the denoiser.
227
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
228
+ counts = torch.bincount(splits["train_tokens"].flatten(), minlength=DNA_SIZE).double()
229
+ frequency = counts / counts.sum() # [4] the GC-rich background the lecture shares.
230
+ for eta in (0.0, 2.0, 10.0):
231
+ half, mean = simulate_jumps(frequency, eta, args.positions, args.steps)
232
+ report[f"masked_at_0.5_eta_{eta:g}"] = round(half, 4) # 1 - t = 0.5 at every eta.
233
+ report[f"jumps_eta_{eta:g}"] = round(mean, 3) # 1 + eta.
234
+
235
+ # Section 5.14.4: the two positions that have to agree.
236
+ report["joint_reveal_at_64"] = round(joint_reveal_rate(64), 4) # 0.0156.
237
+ report["error_at_64_predicted"] = round(0.5 * joint_reveal_rate(64), 4) # Half of those are wrong.
238
+ report["error_at_64_measured"] = f"{simulate_pair(0.0, args.pairs, 64):.5f}" # Near 0.0078.
239
+ report["error_at_grid_measured"] = f"{simulate_pair(0.0, args.pairs, args.steps):.5f}"
240
+ for eta in (0.0, 2.0, 10.0):
241
+ predicted = pair_survival(eta) # exp(-2 eta (1 - tau)) at the midpoint tau = 0.5.
242
+ report[f"survival_eta_{eta:g}"] = f"{predicted:.3e}"
243
+ if predicted * args.pairs >= 20: # Below that, the simulation has nothing to resolve.
244
+ measured = simulate_survival(eta, args.pairs, args.steps) # The same number, simulated.
245
+ report[f"survival_eta_{eta:g}_measured"] = f"{measured:.3e}"
246
+ print_report("correctors", report)
247
+ save_run(args.out, config=vars(args), report=report)
248
+
249
+
250
+ if __name__ == "__main__":
251
+ main()
lecture_5/dfm.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Discrete flow matching, the whole construction in one file.
2
+
3
+ We pick the probability path first, write down a velocity that generates it, and read the sampler off
4
+ the velocity. Each position follows the mixture path between a source token and a data token, its
5
+ conditional velocity is a schedule factor times the difference of two point masses, and the posterior
6
+ average of those conditional velocities is the same factor times a denoiser. Cross-entropy on the
7
+ data token recovers that denoiser, and one Euler step redraws a position from it with probability
8
+ lambda_t. Chapter 4 fixed the corruption and solved for its reverse; here the corruption is a
9
+ consequence of the path we chose.
10
+
11
+ Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
12
+ u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
13
+
14
+ Chapter 5, Sections 5.5 to 5.11. Trained on the synthetic DNA of ``cis6270.data``. Prints the
15
+ chapter's worked numbers, the training loss and the measurements of a generated batch.
16
+
17
+ python lecture_5/dfm.py # train and generate from the uniform source
18
+ python lecture_5/dfm.py --source mask # the all-mask source instead
19
+ python lecture_5/dfm.py --steps 20 --quiet # a few seconds, for a first look
20
+ """
21
+
22
+ # %% 01. Imports and configuration
23
+
24
+ import torch
25
+ import torch.nn.functional as F
26
+ from torch.distributions import Categorical
27
+
28
+ from cis6270.data import DNA_SIZE, decode_dna, dna_splits, encode_dna
29
+ from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
30
+ from cis6270.nets import SequenceDenoiser, count_parameters
31
+ from cis6270.runner import common_parser, print_report, save_run, set_seed, train
32
+
33
+
34
+ # %% 02. The class of Section 5.11, with both sources
35
+ #
36
+ # The constructor holds the vocabulary size V and the schedule kappa_t with its derivative
37
+ # kappa_dot_t, passed as functions of a time tensor. The defaults are the linear schedule
38
+ # kappa_t = t and kappa_dot_t = 1. A mask source returns the index of the mask symbol at every
39
+ # position, with the network's vocabulary raised by one to make room for it.
40
+
41
+
42
+ class DiscreteFlowMatching:
43
+ """The source, the mixture path, the cross-entropy loss and one sampling step."""
44
+
45
+ def __init__(self, V, kappa=lambda t: t, kappa_dot=lambda t: torch.ones_like(t), mask=None):
46
+ self.V, self.k, self.kd = V, kappa, kappa_dot # Alphabet size and the schedule.
47
+ self.mask = mask # Mask token id under the mask source, None under the uniform source.
48
+
49
+ def source(self, shape, device="cpu"):
50
+ if self.mask is None:
51
+ return torch.randint(0, self.V, shape, device=device) # Uniform source, [B, L].
52
+ return torch.full(shape, self.mask, dtype=torch.long, device=device) # All-mask source.
53
+
54
+ def corrupt(self, x1, t):
55
+ """One draw from p_t(. | x_0, x_1) by the coin flip of Section 5.5.5. x1: [B, L], t: [B]."""
56
+ x0 = self.source(x1.shape, x1.device) # The other end of the pair; [B, L].
57
+ heads = torch.rand(x1.shape, device=x1.device) < self.k(t)[:, None] # Heads with prob kappa_t.
58
+ return torch.where(heads, x1, x0) # The data token on heads and the source token on tails.
59
+
60
+
61
+ # %% 03. The conditional velocity
62
+ #
63
+ # Differentiating the conditional path at one position gives kappa_dot_t times the difference of the
64
+ # two indicators, and dividing that flux by the probability 1 - kappa_t still sitting on the source
65
+ # token turns it into a rate out of the token the position currently holds.
66
+
67
+
68
+ def conditional_velocity(z, x1, t, V=DNA_SIZE):
69
+ """u_t(a, z | x_1) = kappa_dot/(1 - kappa) [delta_{x_1}(a) - delta_z(a)]. Returns [V]."""
70
+ factor = 1.0 / (1.0 - t) # Schedule factor on the linear schedule, kappa_dot_t = 1.
71
+ return factor * (F.one_hot(torch.tensor(x1), V) - F.one_hot(torch.tensor(z), V)).double()
72
+
73
+
74
+ # %% 04. The posterior average, collapsed onto the data token
75
+ #
76
+ # The conditional velocity depends on the pair only through the data token at this position, so every
77
+ # pair sharing that token contributes the same term and the sum over pairs collapses onto the four
78
+ # tokens. What is left is the denoiser p_{1|t}(. | x), the only quantity a network has to learn.
79
+
80
+
81
+ def marginal_velocity(p1, x_i, t, V=DNA_SIZE):
82
+ """u^i_t(., x) = kappa_dot/(1 - kappa) [p_{1|t}(. | x) - delta_{x^i}]. p1: [V] -> [V]."""
83
+ factor = 1.0 / (1.0 - t) # Same schedule factor, now multiplying a distribution.
84
+ return factor * (p1 - F.one_hot(torch.tensor(x_i), V).double()) # Signed, sums to zero.
85
+
86
+
87
+ def mask_posterior(data, weights, visible, kappa_t):
88
+ """The posterior over the data token at the masked position, computed by enumeration.
89
+
90
+ ``data`` is a list of candidate sequences and ``weights`` their probabilities under q. Position 0
91
+ is visible and holds ``visible``; position 1 is masked. The time factor is common to every
92
+ candidate and cancels, which is Proposition 6 of Discrete Flow Matching.
93
+ """
94
+ masked, seen = 1, 1 # One masked position and one visible position in this example.
95
+ factor = (1.0 - kappa_t) ** masked * kappa_t ** seen # Common to every surviving candidate.
96
+ unnormalized = torch.zeros(DNA_SIZE, dtype=torch.double) # One entry per base at position 1.
97
+ for sequence, weight in zip(data, weights):
98
+ if sequence[0] == visible: # A candidate disagreeing with the visible token is excluded.
99
+ unnormalized[encode_dna([sequence])[0, 1]] += factor * weight
100
+ return unnormalized / unnormalized.sum() # [V] the denoiser at the masked position.
101
+
102
+
103
+ # %% 05. The cross-entropy loss and its minimizer
104
+ #
105
+ # The loss scores the true data token at every position, with no time weight and no rate in it, so the
106
+ # sampler is chosen after training. Averaging over the data token given X_t splits the expected loss
107
+ # into the entropy of the true denoiser and a KL divergence from it to the network, and only the
108
+ # divergence depends on theta.
109
+
110
+
111
+ def dfm_loss(logits, x1):
112
+ """Cross-entropy of Section 5.9, with the class dimension second. logits: [B, L, V]."""
113
+ return F.cross_entropy(logits.transpose(1, 2), x1) # Mean over the batch and over positions.
114
+
115
+
116
+ def entropy_and_gap(posterior, prediction):
117
+ """The minimum of the loss at one position and the excess a wrong prediction pays, in nats."""
118
+ entropy = -(posterior * posterior.log()).sum() # H(p_{1|t}), the floor no network beats.
119
+ cross = -(posterior * prediction.log()).sum() # The cross-entropy the prediction scores.
120
+ return float(entropy), float(cross - entropy) # The gap is the KL divergence to the prediction.
121
+
122
+
123
+ # %% 06. The Euler step as a two-part rule
124
+ #
125
+ # Substituting the marginal velocity into the per-position Euler step collects the two terms into a
126
+ # mixture of the current point mass and the denoiser. With probability lambda_t the position redraws
127
+ # from the denoiser, and with probability 1 - lambda_t it keeps the token it holds.
128
+
129
+
130
+ def redraw_probability(t, h):
131
+ """lambda_t = h kappa_dot_t / (1 - kappa_t), the redraw probability of Section 5.8."""
132
+ return h / (1.0 - t) # Linear schedule; above one the sampler redraws with certainty.
133
+
134
+
135
+ @torch.no_grad()
136
+ def dfm_step(proc, model, xt, t, h):
137
+ """One two-part step at every position at once. xt: [B, L], t: [B], h a scalar."""
138
+ p1 = model(xt, t)[..., :proc.V].softmax(-1) # [B, L, V] denoiser, the only network call.
139
+ rate = proc.kd(t) / (1 - proc.k(t)).clamp(min=1e-4) # kappa_dot/(1 - kappa), kept finite at t=1.
140
+ redraw = torch.rand(xt.shape, device=xt.device) < (h * rate)[:, None] # [B, L] Bernoulli.
141
+ x1 = Categorical(probs=p1).sample() # [B, L] one candidate token per position.
142
+ return torch.where(redraw, x1, xt) # Keep the candidate where the coin came up true.
143
+
144
+
145
+ # %% 07. Training and generation
146
+ #
147
+ # A training step draws one time per sequence, corrupts the batch at that time, and scores the true
148
+ # tokens. Generation walks a uniform grid of ``sample_steps`` times from t = 0 to t = 1, and the grid
149
+ # is free to change because training never chose a step size.
150
+
151
+
152
+ @torch.no_grad()
153
+ def generate(proc, model, batch, length, steps, device="cpu"):
154
+ """Run the sampler from the source to t = 1 and return the tokens. [B, L]."""
155
+ x = proc.source((batch, length), device) # Draw from the source distribution p.
156
+ grid = torch.linspace(0.0, 1.0, steps + 1, device=device) # The sampling times.
157
+ for t, s in zip(grid[:-1], grid[1:]):
158
+ x = dfm_step(proc, model, x, t.expand(batch), s - t) # One forward pass per step.
159
+ return x
160
+
161
+
162
+ # %% 08. The chapter's worked numbers
163
+ def worked_numbers():
164
+ """The arithmetic Sections 5.5.5, 5.7.4, 5.8, 5.9.2 and 5.9.3 carry out by hand."""
165
+ report = {}
166
+
167
+ # Section 5.5.5: x_1 = ACGT, a uniform source draw x_0 = GTTA, kappa_t = 0.5, coins HTHT.
168
+ x1, x0 = encode_dna(["ACGT"])[0], encode_dna(["GTTA"])[0] # [4] each.
169
+ heads = torch.tensor([True, False, True, False]) # The four coins of the example.
170
+ xt = torch.where(heads, x1, x0) # Data token on heads, source token on tails.
171
+ report["mixture_draw"] = decode_dna(xt[None])[0] # ATGA.
172
+
173
+ # Section 5.7.4: one position holding G, denoiser (0.1, 0.6, 0.2, 0.1), t = 0.5.
174
+ p1 = torch.tensor([0.1, 0.6, 0.2, 0.1], dtype=torch.double) # [V] over A, C, G, T.
175
+ u = marginal_velocity(p1, x_i=2, t=0.5) # Factor 1/0.5 = 2 times the bracket.
176
+ report["velocity_at_G"] = [round(float(v), 4) for v in u] # (0.2, 1.2, -1.6, 0.2).
177
+ report["velocity_sums_to"] = f"{float(u.sum()):.1e}" # Zero, which is the second rule.
178
+ step = F.one_hot(torch.tensor(2), DNA_SIZE).double() + 0.1 * u # One Euler step of size h = 0.1.
179
+ report["euler_step_at_G"] = [round(float(v), 4) for v in step] # (0.02, 0.12, 0.84, 0.02).
180
+ report["redraw_probability"] = round(redraw_probability(0.5, 0.1), 4) # lambda_t = 0.2.
181
+
182
+ # Section 5.9.2: the noisy input ATGA scored against the target ACGT.
183
+ scored = torch.tensor([0.7, 0.2, 0.6, 0.1], dtype=torch.double) # One probability per position.
184
+ report["example_loss_nats"] = round(float(-scored.log().sum()), 3) # 4.780 nats.
185
+
186
+ # Section 5.9.3: length two, the mask source, q(AC) = 0.5, q(AG) = 0.3, q(GC) = 0.2.
187
+ candidates, weights = ["AC", "AG", "GC"], [0.5, 0.3, 0.2]
188
+ half = mask_posterior(candidates, weights, visible="A", kappa_t=0.5) # Denoiser at kappa = 0.5.
189
+ late = mask_posterior(candidates, weights, visible="A", kappa_t=0.8) # The same at kappa = 0.8.
190
+ report["mask_posterior_C_G"] = [round(float(half[1]), 4), round(float(half[2]), 4)]
191
+ report["mask_posterior_unchanged"] = bool(torch.allclose(half, late)) # The time cancels.
192
+ live = half[half > 0] # The two bases the posterior keeps.
193
+ entropy, gap = entropy_and_gap(live, torch.tensor([0.5, 0.5], dtype=torch.double))
194
+ report["posterior_entropy_nats"] = round(entropy, 4) # 0.6616, the floor on the loss.
195
+ report["kl_gap_to_uniform"] = round(gap, 4) # 0.0316 above that floor.
196
+ return report
197
+
198
+
199
+ # %% 09. The report
200
+ def main():
201
+ parser = common_parser(__doc__.split("\n")[0])
202
+ parser.add_argument("--source", choices=["uniform", "mask"], default="uniform")
203
+ parser.set_defaults(steps=1000, width=64, samples=64)
204
+ args = parser.parse_args()
205
+ set_seed(args.seed)
206
+
207
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
208
+ data = splits["train_tokens"] # [N, L] the shared synthetic DNA.
209
+ mask = DNA_SIZE if args.source == "mask" else None # Index 4 is the mask symbol m.
210
+ vocab = DNA_SIZE + 1 if mask is not None else DNA_SIZE # The network's vocabulary.
211
+ proc = DiscreteFlowMatching(DNA_SIZE, mask=mask) # Linear schedule by default.
212
+ model = SequenceDenoiser(vocab, args.length, width=args.width, depth=2)
213
+
214
+ def loss_fn(step):
215
+ rows = torch.randint(0, len(data), (args.batch_size,)) # One minibatch of data sequences.
216
+ x1 = data[rows] # [B, L] the data end of the pair.
217
+ t = torch.rand(args.batch_size) # One time per sequence, uniform on [0, 1].
218
+ xt = proc.corrupt(x1, t) # [B, L] a draw from the conditional path.
219
+ return dfm_loss(model(xt, t)[..., :DNA_SIZE], x1) # Score the true token at every position.
220
+
221
+ if not args.quiet:
222
+ print(f"source={args.source} vocab={vocab} parameters={count_parameters(model):,}")
223
+ losses = train(model, loss_fn, steps=args.steps, lr=args.lr, quiet=args.quiet)
224
+ tokens = generate(proc, model, args.samples, args.length, args.sample_steps)
225
+
226
+ report = worked_numbers()
227
+ report["final_loss_nats"] = sum(losses[-20:]) / len(losses[-20:]) # Averaged over the last steps.
228
+ report["gc_fraction"] = gc_fraction(tokens) # The training data sit at 0.577.
229
+ report["motif_fraction"] = motif_fraction(tokens) # Sequences carrying the planted ACGT.
230
+ report["unique_fraction"] = unique_fraction(tokens) # Catches a collapsed sampler.
231
+ report["token_entropy_nats"] = token_entropy(tokens, DNA_SIZE) # At most log 4 = 1.386.
232
+ print_report(f"discrete flow matching, {args.source} source", report, decode_dna(tokens))
233
+ save_run(args.out, model, vars(args), report, losses, decode_dna(tokens))
234
+
235
+
236
+ if __name__ == "__main__":
237
+ main()
lecture_5/dirichlet.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dirichlet Flow Matching: a token relaxed into a point of the simplex.
2
+
3
+ A straight line toward a corner shrinks its support as t grows, and past t = 1/k at most k - 1
4
+ corners remain possible, which makes the denoiser target discontinuous in time and in space.
5
+ Replacing the line by the Dirichlet family Dir(1 + t e_i) keeps every corner possible at every finite
6
+ time. The velocity of that path points at the data corner and changes only its speed, and matching
7
+ the probability flux across the cut x_i = c solves for that speed.
8
+
9
+ Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
10
+ u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y). On this path the label x_1
11
+ still means the data sample while its subscript stops meaning time one, since time runs to infinity
12
+ and inference stops at t = 8.
13
+
14
+ Chapter 5, Sections 5.19.3 and 5.20.1 to 5.20.5. Trained on the synthetic DNA of ``cis6270.data``.
15
+ Prints the worked speeds, the continuity check, the posterior average, and the measurements of a
16
+ generated batch.
17
+
18
+ python lecture_5/dirichlet.py # train, then 100 Euler steps to t = 8
19
+ python lecture_5/dirichlet.py --steps 20 --quiet # a few seconds, for a first look
20
+ """
21
+
22
+ # %% 01. Imports and configuration
23
+ import math
24
+
25
+ import torch
26
+ import torch.nn as nn
27
+ import torch.nn.functional as F
28
+ from scipy.special import betainc, betaln
29
+
30
+ from cis6270.data import DNA_SIZE, decode_dna, dna_splits
31
+ from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
32
+ from cis6270.nets import SequenceDenoiser, count_parameters
33
+ from cis6270.runner import common_parser, print_report, save_run, set_seed, train
34
+
35
+ T_MAX = 8.0 # The inference horizon, where the data coordinate has mean (t+1)/(t+K) = 0.75 for DNA.
36
+ EDGE = 1e-6 # The speed divides by (1 - x_i) and by x_i, so a coordinate is held off both ends.
37
+
38
+
39
+ # %% 02. The Dirichlet family as a conditional path
40
+ #
41
+ # With the concentration 1 + t in the data coordinate and 1 everywhere else, every exponent except
42
+ # the i-th is zero and the density collapses to a single factor. At t = 0 the path is the uniform
43
+ # distribution on the simplex, and for every finite t it is positive wherever x_i is, so no corner is
44
+ # ever ruled out.
45
+
46
+
47
+ def dirichlet_density(x, i, t, K=DNA_SIZE):
48
+ """p_t(x | x_1 = e_i) = Gamma(t + K)/Gamma(t + 1) x_i^t."""
49
+ prefactor = math.exp(math.lgamma(t + K) - math.lgamma(t + 1.0)) # Gamma(4) = 6 at t = 0.
50
+ return prefactor * float(x[i]) ** t # Constant over the simplex at t = 0.
51
+
52
+
53
+ def sample_dirichlet(targets, t, K=DNA_SIZE):
54
+ """Draw x ~ Dir(1 + t e_i) at every position. targets: [B, L], t: [B] -> [B, L, K]."""
55
+ alpha = torch.ones(*targets.shape, K) + t[:, None, None] * F.one_hot(targets, K) # [B, L, K].
56
+ gamma = torch._standard_gamma(alpha) # One Gamma draw per letter, with unit rate.
57
+ return gamma / gamma.sum(-1, keepdim=True) # Normalizing the Gamma draws gives a Dirichlet.
58
+
59
+
60
+ def straight_line_corners(x, t):
61
+ """How many corners the straight-line path still allows at x, which is the count of x_i >= t."""
62
+ return int((x >= t).sum()) # Proposition 5.5 bounds this by k - 1 once t > 1/k.
63
+
64
+
65
+ # %% 03. The velocity by matching flux across a cut
66
+ #
67
+ # Cutting the simplex at x_i = c, the mass below the cut is the Beta(t+1, K-1) distribution function
68
+ # at c, and its rate of change is the derivative of that function in its first parameter. Setting the
69
+ # loss rate equal to minus the outward flux and solving for the speed leaves one scalar per
70
+ # coordinate, with no closed form at general K.
71
+
72
+
73
+ def d_betainc_da(x, a, b, eps=1e-6):
74
+ """tilde I_x(a, b), the derivative of the regularized incomplete beta function in a."""
75
+ return (betainc(a + eps, b, x) - betainc(a - eps, b, x)) / (2.0 * eps) # A central difference.
76
+
77
+
78
+ def speed(x_i, t, K=DNA_SIZE):
79
+ """C(x_i, t) = -tilde I_{x_i}(t+1, K-1) B(t+1, K-1) / ((1 - x_i)^{K-1} x_i^t).
80
+
81
+ The data coordinate may be a scalar or a tensor of any shape, since every factor broadcasts,
82
+ and a whole batch of relaxed tokens costs the two calls to the incomplete beta function that
83
+ one costs. The central difference subtracts two numbers that agree to about six decimals, and
84
+ at single precision nothing of the difference survives, so this one quantity is computed in
85
+ double whatever the ambient dtype and the caller casts the result back.
86
+ """
87
+ a, b = t + 1.0, float(K - 1) # The two Beta parameters of the data coordinate.
88
+ beta = math.exp(betaln(a, b)) # The complete beta function, 1/12 at a = 2 and b = 3.
89
+ held = torch.as_tensor(x_i, dtype=torch.double).clamp(EDGE, 1.0 - EDGE) # Off both endpoints.
90
+ derivative = torch.as_tensor(d_betainc_da(held.numpy(), a, b)) # Negative, so the speed is not.
91
+ return -derivative * beta / ((1.0 - held) ** (K - 1) * held ** t) # Positive.
92
+
93
+
94
+ def conditional_velocity(x, i, t):
95
+ """u_t(x | x_1 = e_i) = C(x_i, t)(e_i - x), tangent to the simplex. x: [K] -> [K]."""
96
+ direction = F.one_hot(torch.tensor(i), len(x)).to(x) - x # Coordinates summing to zero.
97
+ return float(speed(float(x[i]), t, len(x))) * direction # The speed scales the direction.
98
+
99
+
100
+ # %% 04. Two letters, in closed form
101
+ #
102
+ # With K = 2 the second Beta parameter is one, the incomplete beta function is x^a, and the speed
103
+ # comes out in elementary functions. Differentiating the density in time and the flux in space then
104
+ # cancels term by term, which is the continuity equation at K = 2.
105
+
106
+
107
+ def speed_two_letters(x, t):
108
+ """C(x_1, t) = -x_1 log x_1 / ((t + 1)(1 - x_1)), the K = 2 case of the general formula."""
109
+ return -x * math.log(x) / ((t + 1.0) * (1.0 - x)) # Positive on the whole open interval.
110
+
111
+
112
+ def continuity_terms(x, i, t, h=1e-2):
113
+ """partial_t p and div(p u) in the chart (x_1, ..., x_{K-1}), whose sum the path drives to zero."""
114
+ x = x.to(torch.double) # A stencil on top of a numerical derivative needs the extra digits.
115
+ K = len(x)
116
+
117
+ def density(point):
118
+ return dirichlet_density(point, i, t, K) # The Dirichlet density at a simplex point.
119
+
120
+ def chart(point, coordinate):
121
+ return density(point) * float(conditional_velocity(point, i, t)[coordinate]) # p u_j.
122
+
123
+ time = density(x) * (_digamma(t + K) - _digamma(t + 1.0) + math.log(float(x[i]))) # partial_t p.
124
+ divergence = 0.0
125
+ for j in range(K - 1): # The chart drops the last coordinate, which the sum constraint fixes.
126
+ step = torch.zeros(K, dtype=x.dtype)
127
+ step[j], step[K - 1] = 1.0, -1.0 # A direction inside the simplex, keeping the sum at one.
128
+ divergence += ( # A five-point stencil, since the speed itself is a numerical derivative.
129
+ -chart(x + 2 * h * step, j) + 8 * chart(x + h * step, j)
130
+ - 8 * chart(x - h * step, j) + chart(x - 2 * h * step, j)
131
+ ) / (12.0 * h)
132
+ return time, divergence # Equal and opposite to the precision of the difference.
133
+
134
+
135
+ def _digamma(z):
136
+ """The digamma function, used only to differentiate the Gamma prefactor in time."""
137
+ return float(torch.digamma(torch.tensor(z, dtype=torch.double)))
138
+
139
+
140
+ # %% 05. A worked speed on a DNA base
141
+ #
142
+ # A coordinate already holding a large share has a larger speed, so the flow concentrates gradually
143
+ # and the coordinate in the lead moves fastest toward its corner.
144
+
145
+
146
+ def speed_vector(x, t):
147
+ """C(x_k, t) at every letter of one relaxed base. x: [K] -> [K]."""
148
+ return speed(x, t, x.shape[-1]).to(x) # One vectorized evaluation, cast back to x's dtype.
149
+
150
+
151
+ # %% 06. The posterior average and the loss
152
+ #
153
+ # The data corner is unknown at generation time, so the four conditional velocities combine with the
154
+ # denoiser as weights. Each one is tangent to the simplex and a weighted average of tangent vectors
155
+ # is tangent, so the learned field needs no constraint. Cross-entropy on the data token trains the
156
+ # denoiser, and the conditional velocity appears only inside the sampler.
157
+
158
+
159
+ def marginal_velocity(x, p_hat, t):
160
+ """v_t(x) = sum_k u_t(x | e_k) p_{1|t}(e_k | x). x: [K], p_hat: [K] -> [K]."""
161
+ columns = torch.stack([conditional_velocity(x, k, t) for k in range(len(x))], dim=1) # [K, K].
162
+ return columns @ p_hat # The posterior average of the four conditional velocities.
163
+
164
+
165
+ def batched_velocity(x, p_hat, t):
166
+ """The same average at every position of a batch. x, p_hat: [B, L, K] -> [B, L, K]."""
167
+ speeds = speed(x, t, x.shape[-1]).to(x) # [B, L, K] one speed per coordinate.
168
+ weighted = speeds * p_hat # [B, L, K] the coefficient of each conditional velocity.
169
+ return weighted - weighted.sum(-1, keepdim=True) * x # sum_k w_k (e_k - x), collected.
170
+
171
+
172
+ def relaxed_denoiser(length, width, K=DNA_SIZE, depth=2):
173
+ """The shared sequence denoiser with its lookup table replaced by a map on the relaxed token."""
174
+ model = SequenceDenoiser(K, length, width=width, depth=depth)
175
+ model.token_embedding = nn.Linear(K, width, bias=False) # A point of the simplex, not an index.
176
+ return model
177
+
178
+
179
+ # %% 07. Training on DNA, and sampling by Euler steps
180
+ #
181
+ # Training draws t from an exponential with rate one, places a point on the conditional path, and
182
+ # scores the true letter. Generation starts from the uniform Dirichlet and integrates the marginal
183
+ # velocity to t = 8, projecting back onto the simplex after each step.
184
+
185
+
186
+ @torch.no_grad()
187
+ def generate(model, batch, length, steps, K=DNA_SIZE):
188
+ """Integrate the marginal velocity from the uniform simplex to t = T_MAX. Returns [B, L]."""
189
+ gamma = torch._standard_gamma(torch.ones(batch, length, K)) # Dir(1) is the uniform source.
190
+ x = gamma / gamma.sum(-1, keepdim=True) # [B, L, K].
191
+ h = T_MAX / steps # The time increment, 0.08 over 100 steps.
192
+ for k in range(steps):
193
+ t = k * h
194
+ p_hat = model(x, torch.full((batch,), t)).softmax(-1) # [B, L, K] the denoiser.
195
+ x = x + h * batched_velocity(x, p_hat, t) # One Euler step along the marginal velocity.
196
+ x = x.clamp(min=1e-8) # A large step can push a coordinate below zero.
197
+ x = x / x.sum(-1, keepdim=True) # Back onto the simplex.
198
+ return x.argmax(-1) # Read the token off the largest coordinate.
199
+
200
+
201
+ # %% 08. The report
202
+ def main():
203
+ parser = common_parser(__doc__.split("\n")[0])
204
+ parser.set_defaults(steps=1000, width=64, samples=64, sample_steps=100)
205
+ args = parser.parse_args()
206
+ set_seed(args.seed)
207
+
208
+ report = {
209
+ "density_at_t_0": round(dirichlet_density(torch.full((4,), 0.25), 0, 0.0), 4), # 6.
210
+ "corners_left_at_0.3": straight_line_corners(torch.tensor([0.34, 0.33, 0.33, 0.0]), 0.3),
211
+ "corners_left_at_0.6": straight_line_corners(torch.tensor([0.70, 0.30, 0.0, 0.0]), 0.6),
212
+ "speed_K2_closed": f"{speed_two_letters(0.3, 1.0):.7f}", # 0.2579942.
213
+ "speed_K2_general": f"{float(speed(0.3, 1.0, K=2)):.7f}", # The same to six decimals.
214
+ }
215
+ point = torch.tensor([0.5, 0.3, 0.2], dtype=torch.double) # A base of three letters, at t = 0.8.
216
+ time_term, space_term = continuity_terms(point, 0, 0.8)
217
+ report["continuity_time_term"] = round(time_term, 4) # 0.6355.
218
+ report["continuity_space_term"] = round(space_term, 4) # -0.6355.
219
+ report["continuity_residual"] = f"{time_term + space_term:.2e}" # Their sum, near zero.
220
+
221
+ # Section 5.20.4: K = 4, t = 1 and the relaxed base (0.40, 0.30, 0.20, 0.10).
222
+ x = torch.tensor([0.40, 0.30, 0.20, 0.10], dtype=torch.double)
223
+ report["speeds"] = [round(float(c), 3) for c in speed_vector(x, 1.0)] # 0.232, 0.199, 0.159, 0.104.
224
+ toward_a = conditional_velocity(x, 0, 1.0) # [4] the velocity toward the A corner.
225
+ report["velocity_toward_A"] = [round(float(v), 3) for v in toward_a]
226
+ report["velocity_sums_to"] = f"{float(toward_a.sum()):.1e}" # Zero, so the step stays on it.
227
+
228
+ # Section 5.20.5: the same base with the denoiser (0.60, 0.25, 0.10, 0.05).
229
+ p_hat = torch.tensor([0.60, 0.25, 0.10, 0.05], dtype=torch.double)
230
+ v = marginal_velocity(x, p_hat, 1.0)
231
+ report["marginal_velocity"] = [round(float(c), 4) for c in v] # (0.0553, -0.0133, -0.0262, -0.0158).
232
+ report["euler_step_h_0.1"] = [round(float(c), 4) for c in x + 0.1 * v] # (0.4055, ...).
233
+ report["step_sums_to"] = round(float((x + 0.1 * v).sum()), 9) # One, still on the simplex.
234
+
235
+ # Training on the shared DNA, then generation.
236
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
237
+ data = splits["train_tokens"] # [N, L].
238
+ model = relaxed_denoiser(args.length, args.width)
239
+
240
+ def loss_fn(step):
241
+ rows = torch.randint(0, len(data), (args.batch_size,)) # One minibatch of data sequences.
242
+ x1 = data[rows] # [B, L] the letters the denoiser has to recover.
243
+ t = torch.distributions.Exponential(1.0).sample((args.batch_size,)) # t ~ Exp(1).
244
+ xt = sample_dirichlet(x1, t) # [B, L, K] a point on the conditional path.
245
+ return F.cross_entropy(model(xt, t).transpose(1, 2), x1) # Score the true letter.
246
+
247
+ if not args.quiet:
248
+ print(f"parameters={count_parameters(model):,} horizon={T_MAX}")
249
+ losses = train(model, loss_fn, steps=args.steps, lr=args.lr, quiet=args.quiet)
250
+ tokens = generate(model, args.samples, args.length, args.sample_steps)
251
+
252
+ report["final_loss_nats"] = sum(losses[-20:]) / len(losses[-20:])
253
+ report["gc_fraction"] = gc_fraction(tokens) # The training data sit at 0.577.
254
+ report["motif_fraction"] = motif_fraction(tokens) # Sequences carrying the planted ACGT.
255
+ report["unique_fraction"] = unique_fraction(tokens) # Catches a collapsed sampler.
256
+ report["token_entropy_nats"] = token_entropy(tokens, DNA_SIZE) # At most log 4 = 1.386.
257
+ print_report("Dirichlet flow matching", report, decode_dna(tokens))
258
+ save_run(args.out, model, vars(args), report, losses, decode_dna(tokens))
259
+
260
+
261
+ if __name__ == "__main__":
262
+ main()
lecture_5/edit_flows.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Edit Flows: paths whose sequences change length.
2
+
3
+ Every path so far keeps a single length, because a substitution changes a token and never adds or
4
+ removes one. Enlarging the set of moves to insertions, deletions and substitutions removes that
5
+ constraint. An alignment pads the two ends with a blank until they have the same number of columns,
6
+ the mixture construction then applies column by column, and the alignment is an auxiliary variable we
7
+ marginalize exactly as we marginalized the data sequence. The loss scores rates in place of a
8
+ distribution over tokens, and its minimizer is the rate at which an edit makes progress.
9
+
10
+ Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
11
+ u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
12
+
13
+ Chapter 5, Section 5.17. Runs on the synthetic DNA of ``cis6270.data``, trimmed to varying lengths.
14
+ Prints the alignment of the chapter, the posterior over two alignments, the averaged edit rates, the
15
+ loss of one example, one sampler step, and the length statistics of a run on DNA.
16
+
17
+ python lecture_5/edit_flows.py # 600 sampler steps on 256 DNA pairs
18
+ python lecture_5/edit_flows.py --steps 20 --quiet # a coarse grid, a visibly coarser length
19
+ """
20
+
21
+ # %% 01. Imports and configuration
22
+ import math
23
+
24
+ import torch
25
+
26
+ from cis6270.data import DNA, DNA_SIZE, dna_splits, encode_dna
27
+ from cis6270.runner import common_parser, print_report, save_run, set_seed
28
+
29
+ BLANK = DNA_SIZE # Index 4 is the blank symbol epsilon that padding writes into a column.
30
+
31
+
32
+ # %% 02. Three edits and the rate split
33
+ #
34
+ # At each position of the current sequence there are three edits, and each rate splits into an
35
+ # intensity and a distribution over tokens. A deletion writes no token, so the network emits three
36
+ # intensities and two token distributions per position, which is 3 + 2K numbers in place of the K of
37
+ # the denoiser.
38
+
39
+
40
+ def edit_rates(intensities, tokens=DNA_SIZE):
41
+ """Count the numbers a network emits per position, and the total rate of leaving the sequence."""
42
+ total = sum(sum(kind) for kind in intensities.values()) # -u_t(x, x), the total exit rate.
43
+ return 3 + 2 * tokens, total # Three intensities and two token distributions per position.
44
+
45
+
46
+ # %% 03. Alignments and the column-wise path
47
+ #
48
+ # An alignment puts the positions of the two sequences in correspondence by padding both with the
49
+ # blank. A blank in the target column is a deletion, a blank in the source column an insertion, and
50
+ # two different tokens a substitution. On the aligned pair each column follows the mixture path
51
+ # independently, and removing the blanks from z_t gives the sequence x_t the model reads.
52
+
53
+
54
+ def align(x0, x1):
55
+ """A minimal alignment of two token lists, as the padded pair (z_0, z_1) of equal length."""
56
+ n, m = len(x0), len(x1)
57
+ cost = [[0] * (m + 1) for _ in range(n + 1)] # Edit distance with unit costs.
58
+ for i in range(n + 1):
59
+ cost[i][0] = i # Deleting the first i tokens of the source.
60
+ for j in range(m + 1):
61
+ cost[0][j] = j # Inserting the first j tokens of the target.
62
+ for i in range(1, n + 1):
63
+ for j in range(1, m + 1):
64
+ cost[i][j] = min(cost[i - 1][j] + 1, cost[i][j - 1] + 1,
65
+ cost[i - 1][j - 1] + int(x0[i - 1] != x1[j - 1]))
66
+ z0, z1, i, j = [], [], n, m
67
+ while i or j: # One backtrace through the table, taking the diagonal where it is minimal.
68
+ if i and j and cost[i][j] == cost[i - 1][j - 1] + int(x0[i - 1] != x1[j - 1]):
69
+ z0.append(x0[i - 1]), z1.append(x1[j - 1]), (i := i - 1), (j := j - 1)
70
+ elif i and cost[i][j] == cost[i - 1][j] + 1:
71
+ z0.append(x0[i - 1]), z1.append(BLANK), (i := i - 1) # A deletion column.
72
+ else:
73
+ z0.append(BLANK), z1.append(x1[j - 1]), (j := j - 1) # An insertion column.
74
+ return z0[::-1], z1[::-1] # [C] and [C] over the same number of columns.
75
+
76
+
77
+ def column_kinds(z0, z1):
78
+ """Name the edit each column owes: ``del``, ``ins``, ``sub``, or ``none`` when the two agree."""
79
+ kinds = []
80
+ for a, b in zip(z0, z1):
81
+ kinds.append("none" if a == b else "del" if b == BLANK else "ins" if a == BLANK else "sub")
82
+ return kinds # [C] one label per column.
83
+
84
+
85
+ def draw_state(z0, z1, finished):
86
+ """z_t from a choice of which columns already hold their target, with the blanks removed."""
87
+ held = [b if done else a for a, b, done in zip(z0, z1, finished)] # [C] the aligned state.
88
+ return [token for token in held if token != BLANK] # [|x_t|] the sequence the model reads.
89
+
90
+
91
+ def expected_length(x0, x1, kappa_t):
92
+ """E[|x_t|] = (1 - kappa_t)|x_0| + kappa_t|x_1|, the interpolation one position up."""
93
+ return (1.0 - kappa_t) * len(x0) + kappa_t * len(x1) # Linear from |x_0| to |x_1|.
94
+
95
+
96
+ # %% 04. Averaging over the alignments we cannot see
97
+ #
98
+ # Many alignments produce the same pair, and x_t does not record which one the path is on. Bayes'
99
+ # rule over the alignments weights each by the probability that its column draw produces the observed
100
+ # x_t, and the rates average against those weights. No single alignment has to be chosen.
101
+
102
+
103
+ def alignment_posterior(alignments, observed, kappa_t):
104
+ """p_t(A | x_t) over a list of aligned pairs, by enumerating the column draws of each."""
105
+ weights = []
106
+ for z0, z1 in alignments:
107
+ owed = [i for i, kind in enumerate(column_kinds(z0, z1)) if kind != "none"] # Unfinished.
108
+ total = 0.0
109
+ for mask in range(1 << len(owed)): # Every subset of the owed columns already finished.
110
+ finished = [True] * len(z0)
111
+ probability = 1.0
112
+ for bit, column in enumerate(owed):
113
+ done = bool(mask >> bit & 1)
114
+ finished[column] = done
115
+ probability *= kappa_t if done else 1.0 - kappa_t # The column-wise mixture path.
116
+ if draw_state(z0, z1, finished) == observed:
117
+ total += probability # This draw is consistent with what we see.
118
+ weights.append(total)
119
+ norm = sum(weights)
120
+ return [w / norm for w in weights] # One posterior weight per alignment.
121
+
122
+
123
+ def averaged_rates(alignments, observed, kappa_t, kappa_dot=1.0):
124
+ """The marginal rate of each edit that finishes a column, averaged over the alignments.
125
+
126
+ ``observed`` is the draw in which every column that owes an edit still holds its source symbol,
127
+ which is the state the worked example of Section 5.17.2 reads the rates at.
128
+ """
129
+ rate = kappa_dot / (1.0 - kappa_t) # The weight on every unfinished column.
130
+ posterior, out = alignment_posterior(alignments, observed, kappa_t), {}
131
+ for (z0, z1), weight in zip(alignments, posterior):
132
+ held = [b if kind == "none" else a # A column owing nothing is finished at every time.
133
+ for a, b, kind in zip(z0, z1, column_kinds(z0, z1))]
134
+ for i, (a, b, kind) in enumerate(zip(z0, z1, column_kinds(z0, z1))):
135
+ seen = sum(1 for token in held[:i] if token != BLANK) # Position inside x_t.
136
+ if kind == "sub":
137
+ name = f"sub({seen + 1},{DNA[b]})" # Replace the token at that position.
138
+ elif kind == "ins":
139
+ name = f"ins({seen},{DNA[b]})" # Place a token to the right of that position.
140
+ elif kind == "del":
141
+ name = f"del({seen + 1})" # Remove the token at that position.
142
+ else:
143
+ continue # Nothing is owed here.
144
+ out[name] = out.get(name, 0.0) + weight * rate
145
+ return posterior, out
146
+
147
+
148
+ # %% 05. The training loss
149
+ #
150
+ # The first term is the sum of the model's intensities and pushes all of them down. The second is a
151
+ # weighted cross-entropy on the edits that move x_t toward x_1 and pushes those up. Collecting the
152
+ # terms belonging to one edit gives lambda - w log lambda, whose stationary point is lambda = w.
153
+
154
+
155
+ def edit_loss(total_intensity, owed, kappa_t, kappa_dot=1.0):
156
+ """L = sum of all intensities - sum over unfinished columns of the weight times log lambda."""
157
+ weight = kappa_dot / (1.0 - kappa_t) # kappa_dot/(1 - kappa), which is 2 at t = 0.5.
158
+ return total_intensity - sum(weight * math.log(lam) for lam in owed) # The model's own rates.
159
+
160
+
161
+ def loss_minimizer(weight, grid):
162
+ """The intensity minimizing lambda - w log lambda on a grid, which the derivative puts at w."""
163
+ values = [lam - weight * math.log(lam) for lam in grid] # The contribution of one edit.
164
+ return grid[min(range(len(grid)), key=values.__getitem__)] # Near w, with w/lambda^2 > 0.
165
+
166
+
167
+ # %% 06. One step of the sampler
168
+ #
169
+ # Each position inserts with probability h lambda_ins, and deletes or substitutes with probability
170
+ # h(lambda_del + lambda_sub), the two sharing that draw in proportion to their intensities. Every
171
+ # chosen edit is applied at once, so the step has the same second-order error as the parallel
172
+ # step on a fixed length.
173
+
174
+
175
+ def step_probabilities(intensities, h):
176
+ """The per-position insertion and delete-or-substitute probabilities of one Euler step."""
177
+ insert = [h * lam for lam in intensities["ins"]] # One coin per position.
178
+ change = [h * (d + s) for d, s in zip(intensities["del"], intensities["sub"])] # A second coin.
179
+ return insert, change
180
+
181
+
182
+ def untouched_probability(intensities, h):
183
+ """The chance the step applies no edit anywhere, and the first-order estimate beside it."""
184
+ insert, change = step_probabilities(intensities, h)
185
+ exact = math.prod(1.0 - p for p in insert + change) # Every coin coming up tails.
186
+ _, total = edit_rates(intensities)
187
+ return exact, 1.0 - h * total # The gap is the second-order term from two edits at once.
188
+
189
+
190
+ def expected_length_change(intensities, h):
191
+ """h times the insertion intensities minus the deletion intensities, in tokens."""
192
+ return h * (sum(intensities["ins"]) - sum(intensities["del"])) # Nothing fixes the length.
193
+
194
+
195
+ # %% 07. Edits on the synthetic DNA
196
+ #
197
+ # Each pair of sequences is aligned once, every column that owes an edit finishes at rate
198
+ # kappa_dot/(1 - kappa), and the sequence the model reads is the aligned state with its blanks
199
+ # removed. The realized length wanders while its mean follows the interpolation.
200
+
201
+
202
+ def simulate_columns(pairs, steps, generator=None):
203
+ """Run the column-wise path on a list of aligned pairs. Returns the lengths and the end states."""
204
+ width = max(len(z0) for z0, _ in pairs) # Pad every alignment to the widest one.
205
+ src = torch.full((len(pairs), width), BLANK)
206
+ tgt = torch.full((len(pairs), width), BLANK)
207
+ valid = torch.zeros(len(pairs), width, dtype=torch.bool)
208
+ for row, (z0, z1) in enumerate(pairs):
209
+ src[row, :len(z0)] = torch.tensor(z0)
210
+ tgt[row, :len(z1)] = torch.tensor(z1)
211
+ valid[row, :len(z0)] = True # Columns beyond the alignment take no part.
212
+ done = (src == tgt) & valid # A column whose two symbols agree owes no edit.
213
+ h, halfway = 1.0 / steps, None
214
+ for k in range(steps):
215
+ t = k * h
216
+ if halfway is None and t >= 0.5:
217
+ held = torch.where(done, tgt, src) # [P, C] the aligned state at this time.
218
+ halfway = float(((held != BLANK) & valid).sum(dim=1).double().mean())
219
+ lam = 1.0 if k == steps - 1 else min(1.0, h / max(1.0 - t, 1e-12)) # lambda_t per column.
220
+ coin = torch.rand(src.shape, generator=generator) < lam
221
+ done = done | (coin & valid) # An unfinished column takes its target symbol.
222
+ finished = float((done | ~valid).all(dim=1).double().mean()) # Fraction of pairs that arrived.
223
+ return halfway, finished
224
+
225
+
226
+ # %% 08. The report
227
+ def main():
228
+ parser = common_parser(__doc__.split("\n")[0])
229
+ parser.add_argument("--pairs", type=int, default=256, help="DNA pairs aligned and run")
230
+ args = parser.parse_args()
231
+ set_seed(args.seed)
232
+
233
+ # Section 5.17.1: the chapter's alignment of ACGT with AGAC over five columns.
234
+ x0, x1 = encode_dna(["ACGT"])[0].tolist(), encode_dna(["AGAC"])[0].tolist()
235
+ z0 = x0 + [BLANK] # (A, C, G, T, eps).
236
+ z1 = [x1[0], BLANK] + x1[1:] # (A, eps, G, A, C).
237
+ kinds = column_kinds(z0, z1)
238
+ draw = [True, False, True, True, True] # Column 2 behind, columns 4 and 5 already at the target.
239
+ report = {
240
+ "column_kinds": kinds, # none, del, none, sub, ins.
241
+ "draw_at_kappa_0.5": "".join(DNA[token] for token in draw_state(z0, z1, draw)), # ACGAC.
242
+ "draw_probability": round(0.5 ** sum(kind != "none" for kind in kinds), 4), # 0.125.
243
+ "expected_length": round(expected_length(x0, x1, 0.5), 4), # 4, both ends having length 4.
244
+ "numbers_per_position": edit_rates({"ins": [0.0], "del": [0.0], "sub": [0.0]})[0], # 11.
245
+ }
246
+
247
+ # Section 5.17.2: the pair AC and AAC, which admits two alignments on three columns.
248
+ short, long = encode_dna(["AC"])[0].tolist(), encode_dna(["AAC"])[0].tolist()
249
+ first = ([short[0], short[1], BLANK], long) # Substitute C with A, then insert C.
250
+ second = ([short[0], BLANK, short[1]], long) # Insert A, and leave the last column alone.
251
+ observed = short # The noisy sequence x_t = AC.
252
+ for kappa_t in (0.25, 0.5, 0.75):
253
+ weights = alignment_posterior([first, second], observed, kappa_t)
254
+ report[f"posterior_A1_kappa_{kappa_t}"] = round(weights[0], 4) # (1-kappa)/(2-kappa).
255
+ posterior, rates = averaged_rates([first, second], observed, 0.5)
256
+ for name in sorted(rates):
257
+ report[f"rate_{name}"] = round(rates[name], 3) # 0.667, 1.333 and 0.667.
258
+ report["total_target_rate"] = round(sum(rates.values()), 3) # 2.667.
259
+
260
+ # Section 5.17.3: one unfinished column, a model intensity of 0.8, and 3.0 over all edits.
261
+ report["example_loss"] = round(edit_loss(3.0, [0.8], kappa_t=0.5), 4) # 3.446.
262
+ grid = [0.01 * i for i in range(1, 801)] # Intensities between 0.01 and 8.
263
+ report["loss_minimizer"] = round(loss_minimizer(2.0, grid), 2) # 2, the weight the edit receives.
264
+
265
+ # Section 5.17.4: one whole step on ACG with the chapter's nine intensities.
266
+ intensities = {"ins": [0.2, 0.3, 0.4], "del": [0.1, 0.9, 0.2], "sub": [0.4, 0.3, 0.5]}
267
+ exact, first_order = untouched_probability(intensities, 0.05)
268
+ insert, change = step_probabilities(intensities, 0.05)
269
+ report["insert_probabilities"] = [round(p, 3) for p in insert] # 0.010, 0.015, 0.020.
270
+ report["change_probabilities"] = [round(p, 3) for p in change] # 0.025, 0.060, 0.035.
271
+ report["deletion_given_change"] = round(0.9 / (0.9 + 0.3), 4) # 0.75, three times in four.
272
+ report["untouched"] = round(exact, 4) # 0.8452.
273
+ report["untouched_first_order"] = round(first_order, 4) # 0.835.
274
+ report["second_order_gap"] = round(exact - first_order, 4) # 0.0102.
275
+ report["length_change"] = round(expected_length_change(intensities, 0.05), 4) # -0.015.
276
+
277
+ # The same path on the shared DNA, trimmed so that the two ends have different lengths.
278
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
279
+ tokens = splits["train_tokens"]
280
+ cuts = torch.randint(args.length - 4, args.length + 1, (2 * args.pairs,)) # Varying lengths.
281
+ rows = [tokens[i % len(tokens), :cuts[i]].tolist() for i in range(2 * args.pairs)]
282
+ pairs = [align(rows[2 * i], rows[2 * i + 1]) for i in range(args.pairs)]
283
+ predicted = sum(expected_length(rows[2 * i], rows[2 * i + 1], 0.5) for i in range(args.pairs))
284
+ measured, arrived = simulate_columns(pairs, args.steps)
285
+ report["dna_columns_mean"] = round(sum(len(z0) for z0, _ in pairs) / len(pairs), 3)
286
+ report["dna_length_at_0.5"] = round(measured, 3) # Measured over the run.
287
+ report["dna_length_predicted"] = round(predicted / args.pairs, 3) # The interpolation.
288
+ report["dna_pairs_arrived"] = round(arrived, 4) # Every column finishes by t = 1.
289
+ report["dna_example"] = " -> ".join("".join(DNA[t] for t in row) for row in rows[:2])
290
+ print_report("edit flows", report)
291
+ save_run(args.out, config=vars(args), report=report)
292
+
293
+
294
+ if __name__ == "__main__":
295
+ main()
lecture_5/fisher.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fisher-Flow: the simplex measured by the metric statistics already uses for distributions.
2
+
3
+ The straight-line distance on the simplex measures absolute differences in probability, so dividing a
4
+ rare letter's probability by ten counts for almost nothing. The Fisher information measures how
5
+ distinguishable two nearby distributions are, and by Chentsov's theorem it is the only metric under
6
+ which relabeling the letters or merging two of them is an isometry. Taking the square root of every
7
+ coordinate turns it into the ordinary metric on a sphere, where the conditional path is a
8
+ great-circle arc and the velocity is the remaining arc divided by the remaining time.
9
+
10
+ Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
11
+ u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
12
+
13
+ Chapter 5, Sections 5.21.1 to 5.21.5, with the listing of Section 5.21.7. Trained on the synthetic
14
+ DNA of ``cis6270.data``. Prints the two distances, the worked arc on a DNA base, the tangent
15
+ projection, and the measurements of a generated batch.
16
+
17
+ python lecture_5/fisher.py # train, then 64 exponential-map steps
18
+ python lecture_5/fisher.py --steps 20 --quiet # a few seconds, for a first look
19
+ """
20
+
21
+ # %% 01. Imports and configuration
22
+ import math
23
+
24
+ import torch
25
+ import torch.nn as nn
26
+
27
+ from cis6270.data import DNA_SIZE, decode_dna, dna_splits
28
+ from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
29
+ from cis6270.nets import SequenceDenoiser, count_parameters
30
+ from cis6270.runner import common_parser, print_report, save_run, set_seed, train
31
+
32
+ SMOOTHING = 0.03 # Label smoothing that holds a data corner off the boundary, where the metric blows up.
33
+
34
+
35
+ # %% 02. Small probabilities under the straight-line distance
36
+ #
37
+ # Moving a base from (0.5, 0.5) to (0.4, 0.6) shifts a tenth of the mass between two evenly matched
38
+ # letters. Moving from (0.01, 0.99) to (0.001, 0.999) divides the first probability by ten, which
39
+ # matters for a model of sequences, and the Euclidean distance calls the second move eleven times
40
+ # smaller.
41
+
42
+
43
+ def euclidean_distance(p, q):
44
+ """The straight-line distance between two probability vectors."""
45
+ return float((p - q).norm()) # 0.1414 and 0.0127 for the two moves above.
46
+
47
+
48
+ def fisher_rao_distance(p, q):
49
+ """d_FR(p, q) = 2 arccos(<sqrt(p), sqrt(q)>), at most pi for any two distributions."""
50
+ inner = float((p.sqrt() * q.sqrt()).sum().clamp(max=1.0)) # The angle between square roots.
51
+ return 2.0 * math.acos(inner) # 0.201 and 0.137 for the same two moves.
52
+
53
+
54
+ # %% 03. The Fisher information as a local metric
55
+ #
56
+ # With the probabilities as the parameters, the derivative of the log likelihood of outcome i with
57
+ # respect to p^j is delta_ij/p^j, and the expectation over outcomes collapses the two deltas onto the
58
+ # diagonal. Dividing by p^i makes a change in a rare coordinate count for more.
59
+
60
+
61
+ def fisher_matrix(p):
62
+ """g_jk(p) = delta_jk / p^j, the Fisher information of a categorical distribution. [K, K]."""
63
+ return torch.diag(1.0 / p) # Diagonal, with the reciprocal of each probability.
64
+
65
+
66
+ def fisher_inner(u, v, p):
67
+ """<u, v>_p = sum_i u^i v^i / p^i, the Fisher-Rao inner product of two tangent vectors at p."""
68
+ return float((u * v / p).sum()) # Tangent means the coordinates sum to zero.
69
+
70
+
71
+ # %% 04. The square-root map to the sphere
72
+ #
73
+ # The simplex constraint becomes a norm constraint, so the square roots lie on the unit sphere, in
74
+ # the octant with every coordinate nonnegative. Differentiating the square root shows the squared
75
+ # Euclidean length there is one quarter of the Fisher-Rao length, so the map scaled by two is an
76
+ # isometry.
77
+
78
+
79
+ def to_sphere(p):
80
+ """A probability vector becomes a unit vector. The clamp holds a zero off the boundary."""
81
+ return p.clamp(min=1e-8).sqrt() # simplex -> sphere.
82
+
83
+
84
+ def to_simplex(x):
85
+ """A unit vector with nonnegative coordinates becomes a probability vector."""
86
+ return x ** 2 # sphere -> simplex.
87
+
88
+
89
+ def smooth(one_hot, amount=SMOOTHING):
90
+ """Move a corner inside the simplex, which the metric needs since it diverges at the boundary."""
91
+ K = one_hot.shape[-1]
92
+ return one_hot * (1.0 - K * amount) + amount # (0.91, 0.03, 0.03, 0.03) at amount 0.03.
93
+
94
+
95
+ # %% 05. Geodesics on the sphere
96
+ #
97
+ # The shortest path between two points of a sphere is the arc of the great circle through them, and
98
+ # the conditional path is that arc traversed at constant speed. The logarithm map has length equal to
99
+ # the remaining arc, so dividing by the remaining time leaves the speed constant along the path.
100
+
101
+
102
+ def log_map(x, y):
103
+ """The tangent vector at x reaching y in unit time, of length equal to the arc from x to y."""
104
+ c = (x * y).sum(-1, keepdim=True).clamp(max=1 - 1e-6) # The cosine of the angle between them.
105
+ v = y - c * x # orthogonal to x.
106
+ return v * torch.arccos(c) / v.norm(dim=-1, keepdim=True) # Rescaled to the arc length.
107
+
108
+
109
+ def exp_map(x, w):
110
+ """The point reached by moving along the sphere from x in the direction w for a distance ||w||."""
111
+ n = w.norm(dim=-1, keepdim=True).clamp(min=1e-8) # The length of the tangent vector.
112
+ return torch.cos(n) * x + torch.sin(n) * (w / n) # Unit length by cos^2 + sin^2 = 1.
113
+
114
+
115
+ def geodesic(x0, x1, t):
116
+ """x_t = exp_{x_0}(t log_{x_0}(x_1)), the point a fraction t of the way along the arc."""
117
+ return exp_map(x0, t * log_map(x0, x1)) # the conditional path.
118
+
119
+
120
+ def conditional_velocity(xt, x1, t):
121
+ """u_t(x_t | x_1) = log_{x_t}(x_1)/(1 - t), the remaining arc over the remaining time."""
122
+ return log_map(xt, x1) / (1 - t) # Constant in length along the whole path.
123
+
124
+
125
+ # %% 06. The tangent projection and the regression loss
126
+ #
127
+ # A linear output layer emits an arbitrary vector of R^K, and a velocity on the sphere has to be
128
+ # orthogonal to the current point. Subtracting the component along x enforces that in one line, and
129
+ # the minimizer of the squared loss is the average of the conditional velocities passing through the
130
+ # point, which is the averaging every path in this chapter performs.
131
+
132
+
133
+ def project(xt, raw):
134
+ """v_theta = raw - <x_t, raw> x_t, which is orthogonal to x_t since ||x_t|| = 1."""
135
+ return raw - (xt * raw).sum(-1, keepdim=True) * xt # Any architecture can be used.
136
+
137
+
138
+ def loss(model, x0, x1, t):
139
+ """The squared distance between the projected network output and the geodesic target."""
140
+ xt = geodesic(x0, x1, t[:, None, None]) # [B, L, K] a point on the arc at the sampled time.
141
+ u = log_map(xt, x1) / (1 - t[:, None, None]) # [B, L, K] the remaining arc over the time left.
142
+ raw = model(xt, t) # [B, L, K] unconstrained.
143
+ v = raw - (xt * raw).sum(-1, keepdim=True) * xt # The tangent part of that output.
144
+ return ((v - u) ** 2).sum(-1).mean() # Summed over letters, averaged over positions and batch.
145
+
146
+
147
+ def velocity_network(length, width, K=DNA_SIZE, depth=2):
148
+ """The shared sequence denoiser with its lookup table replaced by a map on the relaxed token.
149
+
150
+ The head emits a vector of R^K here, which ``project`` turns into a velocity on the sphere.
151
+ """
152
+ model = SequenceDenoiser(K, length, width=width, depth=depth)
153
+ model.token_embedding = nn.Linear(K, width, bias=False) # A point on the sphere, not an index.
154
+ return model
155
+
156
+
157
+ # %% 07. Training on DNA, and sampling along the sphere
158
+ #
159
+ # Source points are uniform draws from the simplex, data points are smoothed corners, and the pair is
160
+ # joined by the arc between their square roots. Generation starts from a fresh source point and takes
161
+ # exponential-map steps, so every state stays on the sphere and no renormalization is needed.
162
+
163
+
164
+ def source_points(batch, length, K=DNA_SIZE):
165
+ """Uniform draws from the simplex, mapped to the sphere. Returns [B, L, K]."""
166
+ gamma = torch._standard_gamma(torch.ones(batch, length, K)) # Dir(1) over the simplex.
167
+ return to_sphere(gamma / gamma.sum(-1, keepdim=True)) # [B, L, K] on the unit sphere.
168
+
169
+
170
+ @torch.no_grad()
171
+ def generate(model, batch, length, steps, K=DNA_SIZE):
172
+ """Integrate the learned field from the source to t = 1 with exponential-map steps. [B, L]."""
173
+ x = source_points(batch, length, K) # [B, L, K].
174
+ h = 1.0 / steps
175
+ for k in range(steps):
176
+ t = torch.full((batch,), k * h)
177
+ v = project(x, model(x, t)) # [B, L, K] the tangent part of the network output.
178
+ x = exp_map(x, h * v) # Move along the sphere, which keeps the norm at one.
179
+ return to_simplex(x).argmax(-1) # Read the token off the largest coordinate.
180
+
181
+
182
+ # %% 08. The report
183
+ def main():
184
+ parser = common_parser(__doc__.split("\n")[0])
185
+ parser.set_defaults(steps=1000, width=64, samples=64)
186
+ args = parser.parse_args()
187
+ set_seed(args.seed)
188
+
189
+ near = torch.tensor([0.5, 0.5]), torch.tensor([0.4, 0.6]) # Two evenly matched letters.
190
+ rare = torch.tensor([0.01, 0.99]), torch.tensor([0.001, 0.999]) # One rare letter, divided by ten.
191
+ report = {
192
+ "euclidean_near": round(euclidean_distance(*near), 4), # 0.1414.
193
+ "euclidean_rare": round(euclidean_distance(*rare), 4), # 0.0127, eleven times smaller.
194
+ "fisher_rao_near": round(fisher_rao_distance(*near), 3), # 0.201.
195
+ "fisher_rao_rare": round(fisher_rao_distance(*rare), 3), # 0.137, comparable.
196
+ "two_letters_are_orthogonal": round(
197
+ fisher_rao_distance(torch.tensor([1.0, 0.0]), torch.tensor([0.0, 1.0])), 4), # pi.
198
+ }
199
+
200
+ # Section 5.21.3: a uniform base and a smoothed A, taken to the sphere.
201
+ p0 = torch.full((DNA_SIZE,), 1.0 / DNA_SIZE) # The uniform base.
202
+ p1 = smooth(torch.eye(DNA_SIZE)[0]) # (0.91, 0.03, 0.03, 0.03).
203
+ x0, x1 = to_sphere(p0), to_sphere(p1)
204
+ report["fisher_diagonal"] = [round(float(v), 2) for v in torch.diagonal(fisher_matrix(p0))]
205
+ report["sphere_x1"] = [round(float(v), 3) for v in x1] # (0.954, 0.173, 0.173, 0.173).
206
+ report["inner_product"] = round(float((x0 * x1).sum()), 3) # 0.737.
207
+ report["angle"] = round(math.acos(float((x0 * x1).sum())), 3) # 0.743.
208
+ report["fisher_rao_distance"] = round(fisher_rao_distance(p0, p1), 3) # 1.485, twice the angle.
209
+
210
+ # Section 5.21.4: the midpoint of the arc, and one exponential-map step from it.
211
+ mid = geodesic(x0, x1, torch.tensor(0.5))
212
+ report["midpoint_sphere"] = [round(float(v), 3) for v in mid] # (0.780, 0.361, 0.361, 0.361).
213
+ report["midpoint_simplex"] = [round(float(v), 3) for v in to_simplex(mid)] # (0.609, 0.130, ...).
214
+ remaining = log_map(mid, x1)
215
+ report["log_map_from_mid"] = [round(float(v), 4) for v in remaining] # (0.2323, -0.1672, ...).
216
+ report["remaining_arc"] = round(float(remaining.norm()), 4) # 0.3713, half the total.
217
+ report["constant_speed"] = round(float(conditional_velocity(mid, x1, 0.5).norm()), 3) # 0.743.
218
+ stepped = exp_map(mid, 0.1 * conditional_velocity(mid, x1, 0.5)) # Delta = 0.1 from t = 0.5.
219
+ report["step_sphere"] = [round(float(v), 3) for v in stepped] # (0.824, 0.327, 0.327, 0.327).
220
+ report["step_simplex"] = [round(float(v), 3) for v in to_simplex(stepped)] # (0.680, 0.107, ...).
221
+ report["step_norm"] = round(float(stepped.norm()), 10) # One, because the step followed the arc.
222
+
223
+ # Section 5.21.5: the tangent projection of one raw network output.
224
+ raw = torch.tensor([0.3, -0.2, 0.5, 0.1])
225
+ report["raw_inner_product"] = round(float((mid * raw).sum()), 4) # 0.3785.
226
+ tangent = project(mid, raw)
227
+ report["projected"] = [round(float(v), 3) for v in tangent] # (0.005, -0.337, 0.363, -0.037).
228
+ report["projection_inner"] = f"{float((mid * tangent).sum()):.1e}" # Zero to machine precision.
229
+
230
+ # Training on the shared DNA, then generation.
231
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
232
+ data = splits["train_tokens"] # [N, L].
233
+ model = velocity_network(args.length, args.width)
234
+
235
+ def loss_fn(step):
236
+ rows = torch.randint(0, len(data), (args.batch_size,)) # One minibatch of data sequences.
237
+ target = to_sphere(smooth(torch.eye(DNA_SIZE)[data[rows]])) # [B, L, K] smoothed corners.
238
+ start = source_points(args.batch_size, args.length) # [B, L, K] the independent coupling.
239
+ t = torch.rand(args.batch_size) * 0.98 # Held off t = 1, where the target divides by zero.
240
+ return loss(model, start, target, t)
241
+
242
+ if not args.quiet:
243
+ print(f"parameters={count_parameters(model):,} smoothing={SMOOTHING}")
244
+ losses = train(model, loss_fn, steps=args.steps, lr=args.lr, quiet=args.quiet)
245
+ tokens = generate(model, args.samples, args.length, args.sample_steps)
246
+
247
+ report["final_loss"] = sum(losses[-20:]) / len(losses[-20:])
248
+ report["gc_fraction"] = gc_fraction(tokens) # The training data sit at 0.577.
249
+ report["motif_fraction"] = motif_fraction(tokens) # Sequences carrying the planted ACGT.
250
+ report["unique_fraction"] = unique_fraction(tokens) # Catches a collapsed sampler.
251
+ report["token_entropy_nats"] = token_entropy(tokens, DNA_SIZE) # At most log 4 = 1.386.
252
+ print_report("Fisher-Flow", report, decode_dna(tokens))
253
+ save_run(args.out, model, vars(args), report, losses, decode_dna(tokens))
254
+
255
+
256
+ if __name__ == "__main__":
257
+ main()
lecture_5/guidance.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Guidance: steering a trained flow toward a property by tilting its rates.
2
+
3
+ The distribution we want is the data distribution reweighted by the likelihood of a property, whose
4
+ normalizer runs over every sequence. A rate is a ratio, so that constant cancels, and the conditioned
5
+ process multiplies each rate by the ratio of a value function at the destination to its value at the
6
+ current state. A first-order expansion of the logarithm of that ratio replaces one property
7
+ evaluation per destination with one backward pass, and an exponent on the ratio gives one strength
8
+ parameter, which the classifier-free form reaches from two trained velocities and no classifier.
9
+
10
+ Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
11
+ u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
12
+
13
+ Chapter 5, Sections 5.24 to 5.29, with the listing of Section 5.33. Several objectives at once are in
14
+ ``lecture_5/multi_objective.py``. Trained on the synthetic DNA of ``cis6270.data``, with the GC label
15
+ as the property. Prints the worked guided base, the guidance strength, the Taylor tilt, and the GC
16
+ fraction of a batch generated with and without guidance.
17
+
18
+ python lecture_5/guidance.py # train, then sample at gamma = 0 and gamma = 2
19
+ python lecture_5/guidance.py --gamma 5 # a harder tilt toward the GC-rich label
20
+ python lecture_5/guidance.py --steps 20 --quiet # a few seconds, for a first look
21
+ """
22
+
23
+ # %% 01. Imports and configuration
24
+
25
+ import torch
26
+ import torch.nn as nn
27
+ import torch.nn.functional as F
28
+
29
+ from cis6270.data import DNA_SIZE, decode_dna, dna_splits
30
+ from cis6270.metrics import gc_fraction, motif_fraction, token_entropy
31
+ from cis6270.nets import SequenceDenoiser, TimeEmbedding, count_parameters
32
+ from cis6270.runner import common_parser, print_report, save_run, set_seed, train
33
+
34
+ V = DNA_SIZE + 1 # Four bases plus the mask symbol, which the sampler starts from.
35
+ MASK = DNA_SIZE # Index 4 is the mask symbol m.
36
+
37
+
38
+ def k(t):
39
+ """The schedule kappa_t, linear here."""
40
+ return t
41
+
42
+
43
+ def kd(t):
44
+ """Its derivative kappa_dot_t, which the linear schedule holds at one."""
45
+ return torch.ones_like(t)
46
+
47
+
48
+ # %% 02. Guidance as a tilt of the rates
49
+ #
50
+ # The conditioned velocity is the unguided one multiplied by the ratio of the property probability at
51
+ # the destination to its value at the current state. A move toward a destination the property model
52
+ # scores higher happens sooner, and a destination with rate zero stays at rate zero, so the tilt
53
+ # reweights the support of the model without enlarging it.
54
+
55
+
56
+ def tilt(rates, destination_scores, current_score, gamma=1.0):
57
+ """u^(gamma)_t(y | x, c) = [p(c | y, t)/p(c | x, t)]^gamma u_t(y, x)."""
58
+ return rates * (destination_scores / current_score) ** gamma # gamma = 1 is the exact process.
59
+
60
+
61
+ def h_value(denoiser, destination_scores):
62
+ """h_t(x) = E[p(c | X_1) | X_t = x], the posterior average of the property likelihood."""
63
+ return float((denoiser * destination_scores).sum()) # The exact value at a masked position.
64
+
65
+
66
+ def classifier_free(conditional, unconditional, gamma):
67
+ """u_c^gamma u_u^(1-gamma), the geometric mixture that reaches the same one-parameter family."""
68
+ return conditional ** gamma * unconditional ** (1.0 - gamma) # Linear in the logarithms.
69
+
70
+
71
+ def reachable(positions, letters):
72
+ """D(S - 1) + 1, the number of states one jump can reach, since exactly one position changes."""
73
+ return positions * (letters - 1) + 1 # 2001 for a 100-residue peptide over 21 symbols.
74
+
75
+
76
+ # %% 03. A first-order expansion of the log ratio
77
+ #
78
+ # Embedding a sequence as one-hot vectors makes a single-position change a sparse difference, and
79
+ # expanding the log property probability around the current sequence collapses the inner product onto
80
+ # the difference of two gradient coordinates. One forward and one backward pass then serve every
81
+ # destination.
82
+
83
+
84
+ def taylor_log_ratio(gradient, current):
85
+ """[grad log p_phi]_{i,b} - [grad log p_phi]_{i,a} at one position. gradient: [K] -> [K]."""
86
+ return gradient - gradient[current] # Zero at the letter in place, whose ratio is one.
87
+
88
+
89
+ # %% 04. The guided sampler
90
+ #
91
+ # One guided step differs from the unguided one at a single line, the factor exp(gamma adv). The
92
+ # one-hot encoding has gradients switched on, so the property network can be differentiated against
93
+ # letters that are not currently in place, and summing over the batch before differentiating shares
94
+ # one backward pass across it. The denoiser is read over the four bases alone, since the data token
95
+ # is never the mask, which holds the rate back into the mask at zero and the exit rate at
96
+ # kappa_dot/(1 - kappa) when gamma is zero.
97
+
98
+
99
+ def guided_step(model, prop, xt, t, h, gamma=1.0):
100
+ """One Euler step of the tilted velocity at every position at once. xt: [B, D] -> [B, D]."""
101
+ oh = F.one_hot(xt, V).to(torch.get_default_dtype()).requires_grad_()
102
+ logp = prop(oh, t).sum() # property on noisy x
103
+ g = torch.autograd.grad(logp, oh)[0] # one backward pass
104
+ adv = g - (oh * g).sum(-1, keepdim=True) # Taylor ratio
105
+ with torch.no_grad():
106
+ bases = model(xt, t)[..., :DNA_SIZE].softmax(-1) # denoiser
107
+ p1 = F.pad(bases, (0, V - DNA_SIZE)) # the data token is never the mask
108
+ rate = kd(t) / (1 - k(t)).clamp(min=1e-4)
109
+ u = rate[:, None, None] * p1 * torch.exp(gamma * adv) # every rate tilted
110
+ u.scatter_(-1, xt[..., None], 0.0) # no self-transition
111
+ jump = torch.rand(xt.shape) < h * u.sum(-1)
112
+ y = torch.distributions.Categorical(probs=u).sample()
113
+ return torch.where(jump, y, xt)
114
+
115
+
116
+ class PropertyModel(nn.Module):
117
+ """log p_phi(c | x, t) from a one-hot sequence and a time, trained on noisy intermediate states."""
118
+
119
+ def __init__(self, length, width=64):
120
+ super().__init__()
121
+ self.time_embedding = TimeEmbedding(width, scale=1.0) # The property depends on the time.
122
+ self.body = nn.Sequential(
123
+ nn.Linear(length * V + width, width), nn.SiLU(), nn.Linear(width, 1),
124
+ )
125
+
126
+ def forward(self, oh, t):
127
+ """oh: [B, D, V] one-hot and differentiable, t: [B] -> [B] log probability of the property."""
128
+ h = torch.cat([oh.flatten(1), self.time_embedding(t)], dim=1) # [B, D V + width].
129
+ return F.logsigmoid(self.body(h)).squeeze(-1) # [B] log p_phi(c | x, t).
130
+
131
+
132
+ # %% 05. Training the denoiser and the property model
133
+ #
134
+ # The denoiser is the one built earlier in this chapter, trained from the mask source. The property
135
+ # model reads the same noisy sequences and predicts the GC label of the clean one, so it is a
136
+ # function of an intermediate state and not of a clean sequence.
137
+
138
+
139
+ def corrupt(x1, t):
140
+ """A draw from the conditional path with the all-mask source. x1: [B, D], t: [B] -> [B, D]."""
141
+ heads = torch.rand(x1.shape) < k(t)[:, None] # Heads with probability kappa_t at each position.
142
+ return torch.where(heads, x1, torch.full_like(x1, MASK)) # Data token on heads, mask on tails.
143
+
144
+
145
+ @torch.no_grad()
146
+ def generate(model, prop, batch, length, steps, gamma):
147
+ """Run the guided sampler from all masks to t = 1. Returns [B, D]."""
148
+ x = torch.full((batch, length), MASK, dtype=torch.long) # The all-mask source.
149
+ grid = torch.linspace(0.0, 1.0, steps + 1)
150
+ for t, s in zip(grid[:-1], grid[1:]):
151
+ with torch.enable_grad(): # The property gradient needs a graph inside the no-grad sampler.
152
+ x = guided_step(model, prop, x, t.expand(batch), float(s - t), gamma)
153
+ p1 = model(x, torch.ones(batch))[..., :DNA_SIZE].softmax(-1) # The data token is never the mask.
154
+ drawn = torch.distributions.Categorical(probs=p1).sample() # lambda_t reaching one on the last step.
155
+ return torch.where(x == MASK, drawn, x) # Any position still masked is drawn from the denoiser.
156
+
157
+
158
+ # %% 06. The report
159
+ def main():
160
+ parser = common_parser(__doc__.split("\n")[0])
161
+ parser.add_argument("--gamma", type=float, default=2.0, help="guidance strength")
162
+ parser.set_defaults(steps=1000, width=64, samples=64)
163
+ args = parser.parse_args()
164
+ set_seed(args.seed)
165
+
166
+ # Section 5.27: a masked base at t = 0.6 with the chapter's denoiser and property scores.
167
+ p1 = torch.tensor([0.1, 0.2, 0.3, 0.4], dtype=torch.double) # The denoiser over A, C, G, T.
168
+ rates = p1 / (1.0 - 0.6) # The unguided unmasking rates, 2.5 times the denoiser.
169
+ scores = torch.tensor([0.10, 0.35, 0.55, 0.25], dtype=torch.double) # p(c | a, t).
170
+ guided = tilt(rates, scores, 0.20) # The property model scores the masked state at 0.20.
171
+ report = {
172
+ "unguided_rates": [round(float(r), 4) for r in rates], # (0.25, 0.50, 0.75, 1.00).
173
+ "unguided_exit_rate": round(float(rates.sum()), 4), # 2.50.
174
+ "likelihood_ratios": [round(float(r), 4) for r in scores / 0.20], # (0.50, 1.75, 2.75, 1.25).
175
+ "guided_rates": [round(float(r), 4) for r in guided], # (0.125, 0.875, 2.063, 1.250).
176
+ "guided_exit_rate": round(float(guided.sum()), 4), # 4.3125.
177
+ "guided_destination": [round(float(r), 3) for r in guided / guided.sum()], # (0.029, ...).
178
+ "bayes_products": [round(float(r), 3) for r in p1 * scores], # (0.010, 0.070, 0.165, 0.100).
179
+ "exact_h": round(h_value(p1, scores), 4), # 0.345.
180
+ "exit_rate_at_exact_h": round(float(tilt(rates, scores, h_value(p1, scores)).sum()), 4), # 2.5.
181
+ "reachable_states": reachable(100, 21), # 2001 destinations for a 100-residue peptide.
182
+ }
183
+
184
+ # Section 5.28: the same base at guidance strength two, and the classifier-free form.
185
+ harder = tilt(rates, scores, 0.20, gamma=2.0)
186
+ report["gamma_2_rates"] = [round(float(r), 4) for r in harder] # (0.0625, 1.531, 5.672, 1.563).
187
+ report["gamma_2_exit_rate"] = round(float(harder.sum()), 4) # 8.828.
188
+ report["gamma_2_destination"] = [round(float(r), 3) for r in harder / harder.sum()]
189
+ mixed = classifier_free(tilt(rates, scores, 0.20), rates, gamma=2.0)
190
+ report["classifier_free_agrees"] = bool(torch.allclose(mixed, harder)) # The same family.
191
+
192
+ # Section 5.29: the Taylor tilt against the exact ratio.
193
+ gradient = torch.tensor([-2.10, -1.15, -0.75, -1.30, -1.61], dtype=torch.double) # With the mask.
194
+ approximate = taylor_log_ratio(gradient, MASK)[:DNA_SIZE] # The mask is the letter in place.
195
+ report["taylor_log_ratios"] = [round(float(r), 3) for r in approximate] # (-0.49, 0.46, ...).
196
+ report["exact_log_ratios"] = [round(float(r), 3) for r in (scores / 0.20).log()] # (-0.693, ...).
197
+ taylor_rates = rates * approximate.exp()
198
+ report["taylor_destination"] = [round(float(r), 3) for r in taylor_rates / taylor_rates.sum()]
199
+
200
+ # Training the denoiser and the property model on the shared DNA, then sampling twice.
201
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
202
+ data, labels = splits["train_tokens"], splits["train_labels"] # [N, L] and [N] the GC label.
203
+ model = SequenceDenoiser(V, args.length, width=args.width, depth=2)
204
+ prop = PropertyModel(args.length, width=args.width)
205
+
206
+ def loss_fn(step):
207
+ rows = torch.randint(0, len(data), (args.batch_size,)) # One minibatch of data sequences.
208
+ x1 = data[rows] # [B, L] the sequences the denoiser has to recover.
209
+ y = labels[rows].to(torch.get_default_dtype()) # [B] the property to steer toward.
210
+ t = torch.rand(args.batch_size) # One time per sequence.
211
+ xt = corrupt(x1, t) # [B, L] a draw from the conditional path.
212
+ denoise = F.cross_entropy(model(xt, t)[..., :DNA_SIZE].transpose(1, 2), x1)
213
+ logp = prop(F.one_hot(xt, V).to(torch.get_default_dtype()), t) # [B] the property on x_t.
214
+ property_loss = -(y * logp + (1 - y) * torch.log1p(-logp.exp().clamp(max=1 - 1e-6))).mean()
215
+ return denoise + property_loss # The two networks share one optimizer here.
216
+
217
+ if not args.quiet:
218
+ print(f"denoiser={count_parameters(model):,} property={count_parameters(prop):,}")
219
+ losses = train(model, loss_fn, steps=args.steps, lr=args.lr, quiet=args.quiet,
220
+ extra_parameters=list(prop.parameters()))
221
+ plain = generate(model, prop, args.samples, args.length, args.sample_steps, gamma=0.0)
222
+ steered = generate(model, prop, args.samples, args.length, args.sample_steps, gamma=args.gamma)
223
+
224
+ report["final_loss"] = sum(losses[-20:]) / len(losses[-20:])
225
+ report["gc_fraction_unguided"] = gc_fraction(plain) # The training data sit at 0.577.
226
+ report[f"gc_fraction_gamma_{args.gamma:g}"] = gc_fraction(steered) # Guidance raises it.
227
+ report["motif_fraction_unguided"] = motif_fraction(plain)
228
+ report["token_entropy_unguided"] = token_entropy(plain, DNA_SIZE)
229
+ print_report("guidance", report, decode_dna(steered))
230
+ save_run(args.out, model, vars(args), report, losses, decode_dna(steered))
231
+
232
+
233
+ if __name__ == "__main__":
234
+ main()
lecture_5/gumbel.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gumbel-Softmax Flow Matching: the path comes from the way a token is sampled.
2
+
3
+ Sampling a categorical is a deterministic function of noise. We add a Gumbel draw to each log
4
+ probability and take the largest. Replacing the argmax by a softmax turns the winner into a point of
5
+ the simplex, and cooling the temperature slides that point from the center toward a corner. With the
6
+ noise held fixed the trajectory is a smooth curve, its time derivative is the replicator equation of
7
+ evolutionary dynamics, and setting the noise to zero at inference collapses the velocity to a
8
+ multiple of the direction toward the data corner.
9
+
10
+ Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
11
+ u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
12
+
13
+ Chapter 5, Sections 5.22.1 to 5.22.6. Trained on the synthetic DNA of ``cis6270.data``. Prints the
14
+ Gumbel-max frequencies, the annealed path at four times, the replicator velocity, the marginal
15
+ velocity late in a trajectory, and the measurements of a generated batch.
16
+
17
+ python lecture_5/gumbel.py # train, then 64 Euler steps on the simplex
18
+ python lecture_5/gumbel.py --steps 20 --quiet # a few seconds, for a first look
19
+ """
20
+
21
+ # %% 01. Imports and configuration
22
+ import math
23
+
24
+ import torch
25
+ import torch.nn as nn
26
+ import torch.nn.functional as F
27
+
28
+ from cis6270.data import DNA_SIZE, decode_dna, dna_splits
29
+ from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
30
+ from cis6270.nets import SequenceDenoiser, count_parameters
31
+ from cis6270.runner import common_parser, print_report, save_run, set_seed, train
32
+
33
+ TAU_MAX = 10.0 # The temperature at t = 0, hot enough that every letter holds nearly the same mass.
34
+ LAMBDA = 3.0 # The cooling rate, so tau(1) = 10 exp(-3) = 0.498.
35
+ BETA = 2.0 # The noise scale; a larger beta makes the paths from one data token spread less.
36
+
37
+
38
+ # %% 02. The Gumbel-max trick
39
+ #
40
+ # A Gumbel variable is the negative logarithm of an exponential clock, so the trick is Chapter 4's
41
+ # race between clocks written with logarithms, where the winner is an argmax and the rates are
42
+ # logits. All of the randomness sits in the noise, and the logits are ours to move.
43
+
44
+
45
+ def gumbel(shape, generator=None):
46
+ """g ~ Gumbel(0, 1), as minus the log of minus the log of a uniform draw."""
47
+ u = torch.rand(shape, generator=generator).clamp(min=1e-12, max=1 - 1e-12)
48
+ return -torch.log(-torch.log(u)) # The distribution function is exp(-exp(-x)).
49
+
50
+
51
+ def gumbel_max(probabilities, draws, generator=None):
52
+ """Frequencies of argmax_k [log pi_k + g_k] over ``draws`` samples. Returns [K]."""
53
+ logits = probabilities.log()[None, :] + gumbel((draws, len(probabilities)), generator)
54
+ counts = torch.bincount(logits.argmax(-1), minlength=len(probabilities)).double()
55
+ return counts / counts.sum() # Approaches pi itself.
56
+
57
+
58
+ # %% 03. The softmax relaxation and a cooling temperature
59
+ #
60
+ # A softmax of the noisy logits divided by a temperature is a point of the simplex. As the
61
+ # temperature falls the point approaches the one-hot of the argmax, and as it grows the point
62
+ # approaches the center. The temperature never reaches zero, so the path stays in the interior and
63
+ # its density stays defined.
64
+
65
+
66
+ def temperature(t, tau_max=TAU_MAX, rate=LAMBDA):
67
+ """tau(t) = tau_max exp(-lambda t), which is 10 at t = 0 and 0.498 at t = 1."""
68
+ return tau_max * math.exp(-rate * t) # 2.231 at t = 0.5 and 0.672 at t = 0.9.
69
+
70
+
71
+ # %% 04. The annealed path toward a data token
72
+ #
73
+ # Putting the data token into the logits and letting the cooling do the rest gives the conditional
74
+ # path. The noise is drawn once and held for the whole trajectory, so the only quantity that changes
75
+ # with time is the temperature and the point moves continuously toward the data corner.
76
+
77
+
78
+ def annealed_path(logits, t):
79
+ """psi_t = softmax((e_k + g/beta)/tau(t)), with the logits already assembled. Returns [..., K]."""
80
+ return torch.softmax(logits / temperature(t), dim=-1) # A point of the simplex at every time.
81
+
82
+
83
+ def path_logits(targets, noise, K=DNA_SIZE, beta=BETA):
84
+ """e_k + g/beta at every position. targets: [B, L], noise: [B, L, K] -> [B, L, K]."""
85
+ return F.one_hot(targets, K) + noise / beta # The data indicator plus the scaled Gumbel draw.
86
+
87
+
88
+ # %% 05. Differentiating the path: the replicator equation
89
+ #
90
+ # The only time dependence sits in one over tau, which grows at relative rate lambda. Composing with
91
+ # the softmax Jacobian gives the current mass times the gap between a letter's logit and the
92
+ # mass-weighted average of all of them, which is the replicator equation with fitness
93
+ # lambda ell_i/tau(t).
94
+
95
+
96
+ def replicator_velocity(x, logits, t, rate=LAMBDA):
97
+ """u_{t,i} = (lambda/tau(t)) x_i (ell_i - sum_j x_j ell_j). x, logits: [..., K] -> [..., K]."""
98
+ average = (x * logits).sum(-1, keepdim=True) # The mass-weighted average of the logits.
99
+ return (rate / temperature(t)) * x * (logits - average) # Coordinates summing to zero.
100
+
101
+
102
+ # %% 06. The inference-time velocity
103
+ #
104
+ # The sampler has only the current point, so the noise is set to zero. The logits become the data
105
+ # indicator, the bracket becomes the gap to the data coordinate, and the velocity collapses to a
106
+ # multiple of the direction toward the corner, with the factor x_k(1 - x_k) in the k-th component.
107
+
108
+
109
+ def inference_velocity(x, k, t, rate=LAMBDA):
110
+ """u_t(x | x_1 = e_k) = (lambda/tau(t)) x_k (e_k - x). x: [..., K] -> [..., K]."""
111
+ corner = F.one_hot(torch.tensor(k), x.shape[-1]).to(x) # The data corner.
112
+ return (rate / temperature(t)) * x[..., k, None] * (corner - x) # Zero once x_k reaches zero.
113
+
114
+
115
+ def marginal_velocity(x, p_hat, t, rate=LAMBDA):
116
+ """The posterior average over corners. x, p_hat: [..., K] -> [..., K]."""
117
+ weight = (rate / temperature(t)) * x * p_hat # [..., K] the coefficient of each corner.
118
+ return weight - weight.sum(-1, keepdim=True) * x # sum_k w_k (e_k - x), collected.
119
+
120
+
121
+ # %% 07. Training and the marginal velocity
122
+ #
123
+ # Cross-entropy on the data token trains the denoiser, as it did for the mixture paths and for the
124
+ # Dirichlet path, and the conditional velocity appears nowhere in the gradient. At sampling time the
125
+ # marginal velocity is the posterior average, the fourth appearance of the same rule in this chapter.
126
+
127
+
128
+ def relaxed_denoiser(length, width, K=DNA_SIZE, depth=2):
129
+ """The shared sequence denoiser with its lookup table replaced by a map on the relaxed token."""
130
+ model = SequenceDenoiser(K, length, width=width, depth=depth)
131
+ model.token_embedding = nn.Linear(K, width, bias=False) # A point of the simplex, not an index.
132
+ return model
133
+
134
+
135
+ @torch.no_grad()
136
+ def generate(model, batch, length, steps, K=DNA_SIZE):
137
+ """Integrate the marginal velocity from the center of the simplex to t = 1. Returns [B, L]."""
138
+ x = annealed_path(gumbel((batch, length, K)) / BETA, 0.0) # The source, with no data indicator.
139
+ h = 1.0 / steps
140
+ for k in range(steps):
141
+ t = k * h
142
+ p_hat = model(x, torch.full((batch,), t)).softmax(-1) # [B, L, K] the denoiser.
143
+ x = x + h * marginal_velocity(x, p_hat, t) # One Euler step along the marginal velocity.
144
+ x = x.clamp(min=1e-8) # A large step can drive a coordinate below zero.
145
+ x = x / x.sum(-1, keepdim=True) # Project back onto the simplex.
146
+ return x.argmax(-1) # Read the token off the largest coordinate.
147
+
148
+
149
+ # %% 08. The report
150
+ def main():
151
+ parser = common_parser(__doc__.split("\n")[0])
152
+ parser.add_argument("--draws", type=int, default=400_000, help="draws in the Gumbel-max check")
153
+ parser.set_defaults(steps=1000, width=64, samples=64)
154
+ args = parser.parse_args()
155
+ set_seed(args.seed)
156
+
157
+ pi = torch.tensor([0.5, 0.2, 0.2, 0.1]) # The categorical of the chapter's quick check.
158
+ report = {
159
+ "gumbel_max_frequencies": [round(float(v), 4) for v in gumbel_max(pi, args.draws)],
160
+ "target_probabilities": [round(float(v), 4) for v in pi], # What the frequencies approach.
161
+ "tau_at_0": round(temperature(0.0), 4), # 10.
162
+ "tau_at_1": round(temperature(1.0), 4), # 0.498.
163
+ }
164
+
165
+ # Section 5.22.3: one base annealed toward G with the chapter's fixed noise.
166
+ scaled = torch.tensor([0.0, -0.45, 0.40, -0.05]) # g/beta over A, C, G, T.
167
+ logits = F.one_hot(torch.tensor(2), DNA_SIZE) + scaled # The data indicator at G plus the noise.
168
+ report["path_logits"] = [round(float(v), 2) for v in logits] # (0, -0.45, 1.40, -0.05).
169
+ for t in (0.0, 0.5, 0.9, 1.0):
170
+ point = annealed_path(logits, t)
171
+ report[f"path_at_{t}"] = [round(float(v), 4) for v in point]
172
+
173
+ # Section 5.22.4: the replicator velocity at the midpoint.
174
+ x = annealed_path(logits, 0.5) # (0.2142, 0.1751, 0.4012, 0.2095).
175
+ report["tau_at_0.5"] = round(temperature(0.5), 4) # 2.231.
176
+ report["rate_over_tau"] = round(LAMBDA / temperature(0.5), 3) # 1.345.
177
+ report["average_logit"] = round(float((x * logits).sum()), 4) # 0.4724.
178
+ noisy = replicator_velocity(x, logits, 0.5)
179
+ report["replicator_velocity"] = [round(float(v), 3) for v in noisy] # (-0.136, -0.217, 0.5, -0.147).
180
+ report["replicator_sums_to"] = f"{float(noisy.sum()):.1e}" # Zero, so the step stays on the simplex.
181
+
182
+ # Section 5.22.5: the same point with the noise set to zero.
183
+ clean = inference_velocity(x, 2, 0.5)
184
+ report["inference_velocity"] = [round(float(v), 3) for v in clean] # (-0.116, -0.094, 0.323, ...).
185
+
186
+ # Section 5.22.6: one Euler step late in the trajectory.
187
+ late = torch.tensor([0.138, 0.084, 0.647, 0.131]) # The base at t = 0.8.
188
+ p_hat = torch.tensor([0.05, 0.10, 0.70, 0.15]) # The denoiser output there.
189
+ report["tau_at_0.8"] = round(temperature(0.8), 4) # 0.9072.
190
+ coefficients = (LAMBDA / temperature(0.8)) * late * p_hat
191
+ report["corner_coefficients"] = [round(float(v), 4) for v in coefficients] # (0.0228, ..., 1.4976).
192
+ v = marginal_velocity(late, p_hat, 0.8)
193
+ report["marginal_velocity"] = [round(float(c), 3) for c in v] # (-0.200, -0.108, 0.454, -0.146).
194
+ report["euler_step_h_0.01"] = [round(float(c), 4) for c in late + 0.01 * v] # (0.1360, ...).
195
+
196
+ # Training on the shared DNA, then generation.
197
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
198
+ data = splits["train_tokens"] # [N, L].
199
+ model = relaxed_denoiser(args.length, args.width)
200
+
201
+ def loss_fn(step):
202
+ rows = torch.randint(0, len(data), (args.batch_size,)) # One minibatch of data sequences.
203
+ x1 = data[rows] # [B, L] the letters the denoiser has to recover.
204
+ t = torch.rand(args.batch_size) # One time per sequence, uniform on [0, 1].
205
+ noise = gumbel((args.batch_size, args.length, DNA_SIZE)) # Held fixed along one trajectory.
206
+ cooled = path_logits(x1, noise) / torch.tensor(
207
+ [temperature(float(s)) for s in t])[:, None, None] # Each sequence at its own temperature.
208
+ xt = cooled.softmax(-1) # [B, L, K] a point on the conditional path.
209
+ return F.cross_entropy(model(xt, t).transpose(1, 2), x1) # -log <x_theta, x_1>.
210
+
211
+ if not args.quiet:
212
+ print(f"parameters={count_parameters(model):,} tau_max={TAU_MAX} lambda={LAMBDA}")
213
+ losses = train(model, loss_fn, steps=args.steps, lr=args.lr, quiet=args.quiet)
214
+ tokens = generate(model, args.samples, args.length, args.sample_steps)
215
+
216
+ report["final_loss_nats"] = sum(losses[-20:]) / len(losses[-20:])
217
+ report["gc_fraction"] = gc_fraction(tokens) # The training data sit at 0.577.
218
+ report["motif_fraction"] = motif_fraction(tokens) # Sequences carrying the planted ACGT.
219
+ report["unique_fraction"] = unique_fraction(tokens) # Catches a collapsed sampler.
220
+ report["token_entropy_nats"] = token_entropy(tokens, DNA_SIZE) # At most log 4 = 1.386.
221
+ print_report("Gumbel-Softmax flow matching", report, decode_dna(tokens))
222
+ save_run(args.out, model, vars(args), report, losses, decode_dna(tokens))
223
+
224
+
225
+ if __name__ == "__main__":
226
+ main()
lecture_5/kinetic_optimal.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Kinetic-optimal paths: choosing the velocity, and then the path, by minimizing an energy.
2
+
3
+ Several velocities generate the same path and several paths connect the same endpoints, so a number
4
+ is needed to choose among them. Written in the flux, both constraints are linear and the energy is
5
+ convex, and the minimizer is the probability of the destination times the positive part of a
6
+ difference of logarithmic growth rates. The mixture velocity of this chapter turns out to be that
7
+ minimizer for the path it generates. Picking a distance on the tokens and cooling a softmax of
8
+ negative distances then produces a path that moves probability only toward tokens closer to the data
9
+ token.
10
+
11
+ Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
12
+ u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
13
+
14
+ Chapter 5, Sections 5.16.1 to 5.16.5 and 5.16.7, with the geodesic schedule of 5.16.6. The data marginal
15
+ of the mask-path check is the letter frequency of the synthetic DNA of ``cis6270.data``. Prints the
16
+ kinetic-optimal rates, the worked metric path for DNA, and the energy of two schedules.
17
+
18
+ python lecture_5/kinetic_optimal.py # 600 quadrature points per integral
19
+ python lecture_5/kinetic_optimal.py --steps 20 --quiet # a coarse rule, visibly less accurate
20
+ """
21
+
22
+ # %% 01. Imports and configuration
23
+ import math
24
+
25
+ import torch
26
+
27
+ from cis6270.data import DNA, DNA_SIZE, dna_splits
28
+ from cis6270.runner import common_parser, print_report, save_run, set_seed
29
+
30
+
31
+ torch.set_default_dtype(torch.double) # Everything here is arithmetic, so keep full precision.
32
+ MASK = DNA_SIZE # Index 4 is the mask symbol m, which the mask path visits.
33
+ TRANSITION = torch.tensor([[0, 2, 1, 2], [2, 0, 2, 1], [1, 2, 0, 2], [2, 1, 2, 0]], dtype=torch.double)
34
+ # d = 1 for a transition, A for G or C for T, and d = 2 for a transversion across the two classes.
35
+
36
+
37
+ # %% 02. The flux and its energy
38
+ #
39
+ # The flux along an edge is the velocity times the probability of the state it leaves, and the master
40
+ # equation is linear in it. The cost is a weight over the probability of the current state, times the
41
+ # squared flux, which in the velocity reads as an expected squared rate under p_t.
42
+
43
+
44
+ def flux(u, p):
45
+ """j_t(y, x) = u_t(y, x) p_t(x). u: [K, K], p: [K] -> [K, K]."""
46
+ return u * p[None, :] # Each column scaled by the probability of its current state.
47
+
48
+
49
+ def energy_density(u, p, weight=None):
50
+ """sum_{y != x} w_t(y, x) u_t(y, x)^2 p_t(x), the cost at one time written in the velocity."""
51
+ off = u - torch.diag(torch.diagonal(u)) # The diagonal holds no flux of its own.
52
+ weight = torch.ones_like(u) if weight is None else weight # w = 1 gives the expected squared rate.
53
+ return float((weight * off ** 2 * p[None, :]).sum())
54
+
55
+
56
+ # %% 03. The velocity of least energy
57
+ #
58
+ # Minimizing the energy edge by edge under the master equation, with the weight 1/p_t(y) that makes
59
+ # the cost symmetric in the two ends of an edge, the multiplier comes out as the logarithmic growth
60
+ # rate and the flux becomes p_t(y) p_t(x) times the positive part of its difference. Dividing by
61
+ # p_t(x) returns the velocity.
62
+
63
+
64
+ def kinetic_optimal(p, p_dot):
65
+ """u*_t(y, x) = p_t(y) [d/dt log p_t(y) - d/dt log p_t(x)]_+. p: [K], p_dot: [K] -> [K, K]."""
66
+ growth = torch.where(p > 0, p_dot / p.clamp(min=1e-300), torch.zeros_like(p)) # d/dt log p_t.
67
+ difference = growth[:, None] - growth[None, :] # [K, K] destination minus current state.
68
+ u = p[:, None] * difference.clamp(min=0.0) # Probability moves only toward the faster grower.
69
+ u = u * (p[None, :] > 0) # A state with no probability sends none.
70
+ u.fill_diagonal_(0.0)
71
+ return u - torch.diag(u.sum(dim=0)) # The two rules fix the diagonal.
72
+
73
+
74
+ # %% 04. Kinetic optimality of the mixture path
75
+ #
76
+ # On the conditional mixture path the two growth rates are kappa_dot/kappa at the data token and
77
+ # minus kappa_dot/(1 - kappa) at the source token, and their difference collapses to the conditional
78
+ # velocity built earlier in this chapter. The marginal mask path behaves the same way.
79
+
80
+
81
+ def mask_path(q, kappa_t):
82
+ """p_t and p_dot_t over (A, C, G, T, m) for the mask source with data marginal q. Returns two [5]."""
83
+ p = torch.cat([kappa_t * q, torch.tensor([1.0 - kappa_t])]) # Tokens share kappa_t, m holds 1-kappa.
84
+ p_dot = torch.cat([q.clone(), torch.tensor([-1.0])]) # kappa_dot = 1 on the linear schedule.
85
+ return p, p_dot
86
+
87
+
88
+ def mixture_rate(kappa_t, kappa_dot=1.0):
89
+ """kappa_dot_t/(1 - kappa_t), the conditional velocity out of the source token."""
90
+ return kappa_dot / (1.0 - kappa_t) # 2 at t = 0.5 on the linear schedule.
91
+
92
+
93
+ # %% 05. A distance on the tokens
94
+ #
95
+ # The formula for u* accepts any path, so we can design one. Cooling a softmax of negative distances
96
+ # concentrates the conditional path on the data token, and the normalizing constant cancels between
97
+ # the two logarithms, leaving the probability of the destination times beta_dot times the distance
98
+ # the move gains.
99
+
100
+
101
+ def metric_path(x1, beta_t, distance=TRANSITION):
102
+ """p_t(. | x_1) = softmax(-beta_t d(., x_1)). Returns [K]."""
103
+ return torch.softmax(-beta_t * distance[:, x1], dim=0) # Uniform at beta = 0, a point mass later.
104
+
105
+
106
+ def metric_velocity(x1, beta_t, beta_dot, distance=TRANSITION):
107
+ """u*_t(y, x | x_1) = p_t(y | x_1) beta_dot_t [d(x, x_1) - d(y, x_1)]_+. Returns [K, K]."""
108
+ p = metric_path(x1, beta_t, distance) # [K] the path at this time.
109
+ d = distance[:, x1] # [K] distance from each token to the data token.
110
+ gain = (d[None, :] - d[:, None]).clamp(min=0.0) # [K, K] what the move from x to y gains.
111
+ u = p[:, None] * beta_dot * gain # Faster when the step in distance is larger.
112
+ u.fill_diagonal_(0.0)
113
+ return u - torch.diag(u.sum(dim=0)) # Columns sum to zero.
114
+
115
+
116
+ # %% 06. A worked metric path for DNA
117
+ #
118
+ # A transition swaps a purine for a purine or a pyrimidine for a pyrimidine, and a transversion
119
+ # crosses between the two classes, so d = 1 and d = 2 record which mistakes are small. With
120
+ # beta_t = t/(1 - t) and the data token G, the path at t = 0.5 favors the purine A over the two
121
+ # pyrimidines, and the rates out of C run only toward A and G.
122
+
123
+
124
+ def beta_schedule(t):
125
+ """beta_t = t/(1 - t) with beta_dot_t = 1/(1 - t)^2, which satisfies beta_0 = 0."""
126
+ return t / (1.0 - t), 1.0 / (1.0 - t) ** 2 # 1 and 4 at t = 0.5.
127
+
128
+
129
+ def path_derivative(x1, t, h=1e-6, distance=TRANSITION):
130
+ """d p_t/dt by a central difference, for checking the velocity against the path it generates."""
131
+ forward, backward = metric_path(x1, beta_schedule(t + h)[0], distance), metric_path(
132
+ x1, beta_schedule(t - h)[0], distance)
133
+ return (forward - backward) / (2.0 * h) # [K], compared against u* p_t.
134
+
135
+
136
+ # %% 07. The energy of two schedules
137
+ #
138
+ # On a two-state conditional mixture path the energy is the time integral of kappa_dot squared over
139
+ # kappa times one minus kappa. The linear schedule has a non-integrable singularity at each endpoint,
140
+ # so its energy grows with the truncation and never settles. The geodesic schedule has the constant
141
+ # integrand pi squared, which is the squared arc length of a quarter great circle.
142
+
143
+
144
+ def schedule_energy(kappa, kappa_dot, steps, cut=1e-6):
145
+ """The integral of kappa_dot^2/(kappa (1 - kappa)) over [cut, 1 - cut], by a substituted midpoint.
146
+
147
+ The grid is uniform in s with t = 1/(1 + exp(-s)), whose Jacobian t(1 - t) cancels the endpoint
148
+ singularity exactly for the linear schedule and tames it for any other.
149
+ """
150
+ span = math.log((1.0 - cut) / cut) # The s range that the truncation [cut, 1 - cut] maps to.
151
+ h, total = 2.0 * span / steps, 0.0
152
+ for k in range(steps):
153
+ s = -span + (k + 0.5) * h # Midpoint of the k-th cell.
154
+ t = 1.0 / (1.0 + math.exp(-s)) # Back to the time variable.
155
+ k_t = min(max(kappa(t), 1e-12), 1.0 - 1e-12) # Hold the schedule off both endpoints.
156
+ total += h * t * (1.0 - t) * kappa_dot(t) ** 2 / (k_t * (1.0 - k_t))
157
+ return total
158
+
159
+
160
+ LINEAR = (lambda t: t, lambda t: 1.0) # kappa_t = t.
161
+ GEODESIC = (lambda t: math.sin(math.pi * t / 2) ** 2, # The arc of Section 5.16.6 read as a schedule.
162
+ lambda t: (math.pi / 2) * math.sin(math.pi * t))
163
+
164
+
165
+ def geodesic_schedule(t, source_probability=0.0):
166
+ """kappa_t(x_1) = 1 - sin^2((1 - t) Omega)/sin^2 Omega with Omega = arccos(sqrt(p(x_1)))."""
167
+ omega = math.acos(math.sqrt(source_probability)) # pi/2 under the mask source.
168
+ return 1.0 - math.sin((1.0 - t) * omega) ** 2 / math.sin(omega) ** 2
169
+
170
+
171
+ # %% 08. The report
172
+ def main():
173
+ parser = common_parser(__doc__.split("\n")[0])
174
+ args = parser.parse_args()
175
+ set_seed(args.seed)
176
+
177
+ # Section 5.16.3: the mask path at t = 0.5 with the chapter's data marginal.
178
+ q = torch.tensor([0.1, 0.6, 0.2, 0.1]) # [4] over A, C, G, T.
179
+ p, p_dot = mask_path(q, kappa_t=0.5)
180
+ u_star = kinetic_optimal(p, p_dot)
181
+ report = {
182
+ "mask_path_ACGTm": [round(float(v), 4) for v in p], # (0.05, 0.30, 0.10, 0.05, 0.50).
183
+ "growth_at_mask": round(float(p_dot[MASK] / p[MASK]), 4), # -2.
184
+ "growth_at_token": round(float(p_dot[1] / p[1]), 4), # +2.
185
+ "rate_mask_to_C": round(float(u_star[1, MASK]), 4), # 0.30 times 4, which is 1.2.
186
+ "mixture_rate_to_C": round(mixture_rate(0.5) * float(q[1]), 4), # 2 times 0.6, the same.
187
+ "rate_between_tokens": round(float(u_star[1, 2]), 6), # Zero, the no-revision mask path.
188
+ "generates_the_path": bool(torch.allclose(u_star @ p, p_dot, atol=1e-9)),
189
+ }
190
+
191
+ # The same check with the letter frequency of the synthetic DNA as the data marginal.
192
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
193
+ counts = torch.bincount(splits["train_tokens"].flatten(), minlength=DNA_SIZE).double()
194
+ frequency = counts / counts.sum() # [4] the GC-rich background the lecture shares.
195
+ p_dna, p_dot_dna = mask_path(frequency, kappa_t=0.5)
196
+ u_dna = kinetic_optimal(p_dna, p_dot_dna)
197
+ report["dna_marginal"] = [round(float(v), 4) for v in frequency]
198
+ report["dna_rate_mask_to_G"] = round(float(u_dna[2, MASK]), 4) # 2 times the frequency of G.
199
+ report["dna_energy_density"] = round(energy_density(u_dna, p_dna), 4) # Expected squared rate.
200
+ report["dna_flux_mask_to_G"] = round(float(flux(u_dna, p_dna)[2, MASK]), 4) # Rate times p_t(m).
201
+
202
+ # Section 5.16.5: the metric path toward G at t = 0.5.
203
+ g, c = DNA.index("G"), DNA.index("C")
204
+ beta_t, beta_dot = beta_schedule(0.5) # 1 and 4.
205
+ p_metric = metric_path(g, beta_t)
206
+ u_metric = metric_velocity(g, beta_t, beta_dot)
207
+ report["metric_path_at_0.5"] = [round(float(v), 4) for v in p_metric]
208
+ report["rate_C_to_A"] = round(float(u_metric[0, c]), 3) # 0.898.
209
+ report["rate_C_to_G"] = round(float(u_metric[g, c]), 3) # 4.882.
210
+ report["rate_A_to_G"] = round(float(u_metric[g, 0]), 3) # 2.441.
211
+ report["rate_out_of_C"] = round(float(-u_metric[c, c]), 3) # 5.780.
212
+ step = torch.eye(DNA_SIZE)[c] + 0.05 * u_metric[:, c] # One Euler step of size h = 0.05 from C.
213
+ report["euler_step_from_C"] = [round(float(v), 4) for v in step]
214
+ report["p_dot_from_velocity"] = [round(float(v), 4) for v in u_metric @ p_metric]
215
+ report["p_dot_from_path"] = [round(float(v), 4) for v in path_derivative(g, 0.5)]
216
+
217
+ # Section 5.16.7: the energy of the two schedules, and the geodesic schedule itself.
218
+ report["energy_linear_1e-6"] = round(schedule_energy(*LINEAR, args.steps, 1e-6), 2) # 27.63.
219
+ report["energy_linear_1e-9"] = round(schedule_energy(*LINEAR, args.steps, 1e-9), 2) # 41.45.
220
+ report["energy_geodesic"] = round(schedule_energy(*GEODESIC, args.steps, 1e-6), 4) # 9.8696.
221
+ report["pi_squared"] = round(math.pi ** 2, 4) # The squared arc length of a quarter circle.
222
+ report["geodesic_kappa_0.25"] = round(geodesic_schedule(0.25), 4) # 0.1464.
223
+ report["geodesic_kappa_0.75"] = round(geodesic_schedule(0.75), 4) # 0.8536.
224
+ report["geodesic_rate_0.9"] = round(math.pi * math.tan(math.pi * 0.9 / 2), 4) # 19.84.
225
+ report["uniform_source_kappa_0.5"] = round(geodesic_schedule(0.5, 0.25), 4) # 0.6667.
226
+ print_report("kinetic-optimal paths", report)
227
+ save_run(args.out, config=vars(args), report=report)
228
+
229
+
230
+ if __name__ == "__main__":
231
+ main()
lecture_5/multi_objective.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Several objectives at once: MOG-DFM's cone and pCoMole's rollout.
2
+
3
+ A peptide is scored on binding, solubility and half-life together, and no dataset labels one
4
+ sequence for all of them, so the single value function of Discrete Guidance is out of reach.
5
+ MOG-DFM drops the ratio form and builds a tilt out of a rank inside each objective and a direction
6
+ against a weight vector, with a cone that removes the candidates pointing the wrong way. pCoMole
7
+ keeps the ratio and replaces the property likelihood by an exponentiated worst-objective utility,
8
+ whose value at a candidate is the average terminal reward over rollouts of the base flow.
9
+
10
+ Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
11
+ u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
12
+
13
+ Chapter 5, Sections 5.30 to 5.32. The single-objective tilt is in ``lecture_5/guidance.py``. The
14
+ rollout runs on the synthetic DNA of ``cis6270.data``, scored by GC fraction against AT fraction.
15
+ Prints the Pareto front, the chapter's MOG-DFM step with its cone, the chapter's pCoMole rollout,
16
+ and a rollout on DNA whose value reverses the base rates.
17
+
18
+ python lecture_5/multi_objective.py # 600 rollouts per candidate on DNA
19
+ python lecture_5/multi_objective.py --steps 4000 # a tighter estimate of the second value
20
+ python lecture_5/multi_objective.py --steps 20 --quiet # twenty rollouts, for a first look
21
+ """
22
+
23
+ # %% 01. Imports and configuration
24
+ import math
25
+
26
+ import torch
27
+
28
+ from cis6270.data import DNA, DNA_SIZE, MOTIF, decode_dna, dna_splits
29
+ from cis6270.runner import common_parser, print_report, save_run, set_seed
30
+
31
+ MASK = DNA_SIZE # Index 4 is the mask symbol m, which a candidate still holds off its own position.
32
+ OMEGA = torch.tensor([0.5, 0.5], dtype=torch.double) # The trade-off the DNA rollout asks for.
33
+ REFERENCE = torch.tensor([0.4, 0.3], dtype=torch.double) # The levels r_k the objectives sit above.
34
+ BETA = 2.0 # The inverse temperature of the terminal reward.
35
+
36
+
37
+ # %% 02. Several objectives at once
38
+ #
39
+ # With several objectives there is no single best sequence. A candidate is on the Pareto front when
40
+ # no other candidate matches it everywhere and beats it somewhere, and a weight vector picks one
41
+ # point of that front by collapsing the scores to a single weighted number.
42
+
43
+
44
+ def dominated(scores, row):
45
+ """True when some other candidate is at least as good everywhere and better somewhere."""
46
+ others = torch.cat([scores[:row], scores[row + 1:]]) # Every candidate except this one.
47
+ weak = (others >= scores[row]).all(dim=1) # At least as good on every objective.
48
+ strict = (others > scores[row]).any(dim=1) # Strictly better on at least one.
49
+ return bool((weak & strict).any())
50
+
51
+
52
+ def pareto_front(scores):
53
+ """The indices of the candidates no other candidate dominates. scores: [M, N] -> list."""
54
+ return [row for row in range(len(scores)) if not dominated(scores, row)]
55
+
56
+
57
+ # %% 03. MOG-DFM: rank, direction and a cone
58
+ #
59
+ # Without a joint likelihood there is no ratio to compute, so every candidate letter is scored twice.
60
+ # Ranking inside each objective removes the units, the inner product with the weight vector measures
61
+ # direction, and the two z-scored terms exponentiate into a tilt on the pretrained rate.
62
+
63
+
64
+ def rank_scores(changes):
65
+ """I_n(y^i, x), the rank of each candidate's change within each objective, over the alphabet size."""
66
+ order = changes.argsort(dim=0).argsort(dim=0) + 1 # Rank one for the smallest change.
67
+ return order.to(changes.dtype) / changes.shape[0] # [K, N] in {1/K, ..., 1}.
68
+
69
+
70
+ def z_score(values):
71
+ """Norm[.], the z-score across the candidate letters at one position."""
72
+ spread = values.std(unbiased=False).clamp(min=1e-12) # The population deviation, held off zero.
73
+ return (values - values.mean()) / spread # Zero everywhere when no candidate stands out.
74
+
75
+
76
+ def combined_score(changes, importance, weights, lam=1.0):
77
+ """Delta S = Norm[mean_n i_n I_n] + lambda Norm[Delta s . omega]. changes: [K, N] -> [K]."""
78
+ ranks = (rank_scores(changes) * importance).mean(dim=1) # Importance-weighted average of ranks.
79
+ direction = changes @ weights # [K] the directional score of each candidate.
80
+ return z_score(ranks) + lam * z_score(direction), ranks, direction
81
+
82
+
83
+ # %% 04. Accepting a move only inside a cone
84
+ #
85
+ # A z-scored score puts about half the candidates above zero, so the tilt alone promotes the least
86
+ # bad letter at a position where every letter moves away from the requested trade-off. The angle
87
+ # between a candidate's score change and omega removes those, and a controller on the logarithm of
88
+ # the angle holds the surviving fraction near one minus the target rejection rate.
89
+
90
+
91
+ def cone_angles(changes, weights):
92
+ """alpha^i = arccos(Delta s . omega / (||Delta s|| ||omega||)), in degrees."""
93
+ cosine = (changes @ weights) / (changes.norm(dim=1) * weights.norm()) # [K].
94
+ return torch.rad2deg(torch.arccos(cosine.clamp(-1.0, 1.0))), cosine
95
+
96
+
97
+ def update_cone(phi, rejected, target=0.2, gain=0.5, low=30.0, high=90.0):
98
+ """Phi_{t+h} = clip(Phi_t exp(eta (r - tau)), Phi_min, Phi_max), proportional control on log Phi."""
99
+ return min(max(phi * math.exp(gain * (rejected - target)), low), high) # Wider when it rejects more.
100
+
101
+
102
+ # %% 05. pCoMole: a reward in place of a likelihood
103
+ #
104
+ # Scoring a sequence by its weakest objective stops the sampler from trading one property away, and a
105
+ # small multiple of the sum breaks ties. The exponentiated utility times a feasibility indicator is
106
+ # the terminal reward, and the value of a candidate is the average reward over rollouts of the base
107
+ # flow started from it.
108
+
109
+
110
+ def utility(scores, weights, reference, rho=0.1):
111
+ """U(x) = min_k omega_k (f_k(x) - r_k) + rho sum_k omega_k (f_k(x) - r_k)."""
112
+ gaps = weights * (scores - reference) # [N] the weighted gap on each objective.
113
+ return float(gaps.min() + rho * gaps.sum()) # The minimum, with the tie-breaking correction.
114
+
115
+
116
+ def terminal_reward(scores, weights, reference, beta, feasible, rho=0.1):
117
+ """G(x) = exp(beta U(x)) 1[x in F], which is zero for an infeasible sequence."""
118
+ return math.exp(beta * utility(scores, weights, reference, rho)) * float(feasible)
119
+
120
+
121
+ def rollout_value(rollouts, feasible, weights, reference, beta, rho=0.1):
122
+ """hat h_t(y_c), the average terminal reward over R continuations of the base flow."""
123
+ rewards = [terminal_reward(s, weights, reference, beta, f, rho) for s, f in zip(rollouts, feasible)]
124
+ return sum(rewards) / len(rewards), rewards # A candidate with no feasible ending scores zero.
125
+
126
+
127
+ # %% 06. A rollout on the synthetic DNA
128
+ #
129
+ # The base flow of a rollout is the mask-source sampler of this chapter with the letter frequency of
130
+ # the data as its denoiser, so a continuation costs no network call. Two objectives that pull
131
+ # against each other are the GC fraction and the AT fraction of the finished sequence, and the
132
+ # feasible set is the sequences that carry the planted motif.
133
+
134
+
135
+ def continuations(partial, frequency, rollouts, generator=None):
136
+ """Fill every masked position of a candidate from the base flow. partial: [L] -> [R, L]."""
137
+ grid = partial[None, :].repeat(rollouts, 1) # [R, L] one copy of the candidate per rollout.
138
+ masked = grid == MASK # [R, L] the positions the flow still owes a letter.
139
+ draws = torch.multinomial(frequency, int(masked.sum()), replacement=True, generator=generator)
140
+ return grid.masked_scatter(masked, draws) # [R, L] the terminal sequences of the rollouts.
141
+
142
+
143
+ def dna_objectives(sequences):
144
+ """The GC fraction and the AT fraction of each sequence, which sum to one. [R, L] -> [R, 2]."""
145
+ gc = ((sequences == 1) | (sequences == 2)).to(torch.get_default_dtype()).mean(dim=1) # C and G.
146
+ return torch.stack([gc, 1.0 - gc], dim=1) # [R, 2] raising one lowers the other.
147
+
148
+
149
+ def carries_motif(sequences):
150
+ """One per sequence that holds the planted motif, which is the hard constraint here. [R] ints."""
151
+ return [int(MOTIF in sequence) for sequence in decode_dna(sequences)]
152
+
153
+
154
+ def dna_rollout_value(partial, frequency, rollouts, weights, reference, beta, generator=None):
155
+ """hat h_t at one candidate, from ``rollouts`` continuations of the base flow. Returns a float."""
156
+ finished = continuations(partial, frequency, rollouts, generator) # [R, L].
157
+ scores = dna_objectives(finished) # [R, 2] the two objectives at the end of each rollout.
158
+ value, _ = rollout_value(list(scores), carries_motif(finished), weights, reference, beta)
159
+ return value # Zero when no continuation of this candidate is feasible.
160
+
161
+
162
+ # %% 07. The report
163
+ def main():
164
+ parser = common_parser(__doc__.split("\n")[0])
165
+ args = parser.parse_args()
166
+ set_seed(args.seed)
167
+
168
+ # Section 5.30: four candidates on two objectives, and the two weight vectors.
169
+ candidates = torch.tensor([[0.8, 0.3], [0.6, 0.7], [0.5, 0.2], [0.6, 0.3]], dtype=torch.double)
170
+ front = pareto_front(candidates)
171
+ report = {
172
+ "pareto_front": front, # Candidates 1 and 2; 3 and 4 are dominated.
173
+ "weighted_at_0.6_0.4": [round(float(s), 3) for s in candidates @ torch.tensor(
174
+ [0.6, 0.4], dtype=torch.double)], # 0.60 for the first and 0.64 for the second.
175
+ "weighted_at_0.8_0.2": [round(float(s), 3) for s in candidates @ torch.tensor(
176
+ [0.8, 0.2], dtype=torch.double)], # 0.70 and 0.62, which reverses the order.
177
+ }
178
+
179
+ # Section 5.31.2: one MOG-DFM step at a position holding A, with three candidate letters.
180
+ changes = torch.tensor([[0.0, 0.0], [0.20, -0.05], [0.05, 0.15], [-0.10, 0.08]], dtype=torch.double)
181
+ importance = torch.tensor([1.2, 0.8], dtype=torch.double)
182
+ weights = torch.tensor([0.6, 0.4], dtype=torch.double)
183
+ delta_s, ranks, direction = combined_score(changes, importance, weights)
184
+ report["rank_average"] = [round(float(r), 3) for r in ranks] # (0.500, 0.700, 0.850, 0.450).
185
+ report["directional_scores"] = [round(float(d), 3) for d in direction] # (0, 0.100, 0.090, -0.028).
186
+ report["combined_score"] = [round(float(s), 3) for s in delta_s] # (-1.511, 1.541, 2.297, -2.327).
187
+ posterior = torch.tensor([0.3, 0.3, 0.3, 0.1], dtype=torch.double) # The denoiser at t = 0.7.
188
+ base = posterior[1:] / (1.0 - 0.7) # The pretrained rates out of A toward C, G and T.
189
+ report["base_rates"] = [round(float(r), 4) for r in base] # (1.000, 1.000, 0.333).
190
+ tilted = base * delta_s[1:].exp()
191
+ report["guided_rates"] = [round(float(r), 3) for r in tilted] # (4.667, 9.948, 0.033).
192
+ angles, cosines = cone_angles(changes[1:], weights)
193
+ report["cone_cosines"] = [round(float(c), 4) for c in cosines] # (0.6727, 0.7894, -0.3032).
194
+ report["cone_angles_deg"] = [round(float(a), 1) for a in angles] # (47.7, 37.9, 107.7).
195
+ inside = angles <= 60.0 # The cone test at Phi = 60 degrees.
196
+ survivors = tilted[inside]
197
+ report["after_cone"] = [round(float(r), 3) for r in survivors / survivors.sum()] # (0.319, 0.681).
198
+ rejected = float((~inside).to(torch.get_default_dtype()).mean()) # One candidate of three.
199
+ report["next_cone_angle"] = round(update_cone(60.0, rejected), 1) # 64.1.
200
+
201
+ # Section 5.32.2: three candidate edits, each rolled out four times.
202
+ omega, reference = torch.tensor([0.5, 0.5], dtype=torch.double), torch.zeros(2, dtype=torch.double)
203
+ edits = [
204
+ ([(0.8, 0.4), (0.6, 0.6), (0.9, 0.1), (0.5, 0.5)], [1, 1, 0, 1], 0.6),
205
+ ([(1.0, 0.0), (0.9, 0.1), (0.95, 0.05), (1.0, 0.1)], [1, 1, 1, 1], 1.2),
206
+ ([(0.4, 0.4)] * 4, [0, 0, 0, 0], 2.0),
207
+ ]
208
+ values = [rollout_value([torch.tensor(s, dtype=torch.double) for s in rollouts],
209
+ feasible, omega, reference, 2.0)[0] for rollouts, feasible, _ in edits]
210
+ base_rates = torch.tensor([rate for _, _, rate in edits], dtype=torch.double)
211
+ products = torch.tensor(values) * base_rates # The base rate times the continuation value.
212
+ report["rollout_values"] = [round(v, 3) for v in values] # (1.390, 1.181, 0).
213
+ report["rollout_products"] = [round(float(p), 3) for p in products] # (0.834, 1.417, 0).
214
+ report["rollout_selection"] = [round(float(p), 3) for p in products / products.sum()]
215
+ report["rollout_base_selection"] = [round(float(p), 3) for p in base_rates / base_rates.sum()]
216
+
217
+ # The same rollout on the shared DNA, from a partly revealed sequence with ACG already in place.
218
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
219
+ counts = torch.bincount(splits["train_tokens"].flatten(), minlength=DNA_SIZE).double()
220
+ frequency = counts / counts.sum() # [4] the denoiser of the base flow, GC-rich like the data.
221
+ # The base rates below are kappa_dot/(1 - kappa) times that denoiser, read at t = 0.3.
222
+ revealed = torch.full((args.length,), MASK, dtype=torch.long)
223
+ revealed[2:5] = torch.tensor([0, 1, 2]) # Positions 3, 4 and 5 hold A, C and G.
224
+ finish = {letter: revealed.clone() for letter in "TC"}
225
+ for letter, candidate in finish.items():
226
+ candidate[5] = DNA.index(letter) # Position 6 takes T, completing ACGT, or C, which does not.
227
+ seen = {letter: dna_rollout_value(candidate, frequency, args.steps, OMEGA, REFERENCE, BETA)
228
+ for letter, candidate in finish.items()}
229
+ rates = torch.tensor([float(frequency[DNA.index(letter)]) for letter in finish]) / (1.0 - 0.3)
230
+ guided = rates * torch.tensor([seen[letter] for letter in finish]) # The base rate times hat h.
231
+ report["dna_rollout_value_T"] = round(seen["T"], 4) # Every continuation carries the motif.
232
+ report["dna_rollout_value_C"] = round(seen["C"], 4) # Only the ones that plant it elsewhere.
233
+ report["dna_base_rates_T_C"] = [round(float(r), 4) for r in rates] # The GC-rich data prefers C.
234
+ report["dna_base_selection"] = [round(float(r), 4) for r in rates / rates.sum()]
235
+ report["dna_guided_selection"] = [round(float(r), 4) for r in guided / guided.sum()]
236
+ print_report("several objectives at once", report)
237
+ save_run(args.out, config=vars(args), report=report)
238
+
239
+
240
+ if __name__ == "__main__":
241
+ main()
lecture_5/rectify.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rectification and AReUReDi: changing the coupling so that fewer steps suffice.
2
+
3
+ A one-step sampler draws every position from its own marginal, so whatever structure the data has
4
+ between positions is lost in that draw. The error it makes is the conditional total correlation of
5
+ the coupling, which is a property of how the pairs were drawn and not of the data. Training on the
6
+ current pairs, sampling from the model, and keeping the pairs it produces lowers that total
7
+ correlation, and the discrete counterpart of the transport-cost bound says it never raises it.
8
+ AReUReDi then composes a rectified flow with a Metropolis refinement whose proposal starts from the
9
+ model's own per-position distribution.
10
+
11
+ Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
12
+ u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
13
+
14
+ Chapter 5, Sections 5.36 to 5.41. The second recoupling runs on adjacent base pairs of the synthetic
15
+ DNA of ``cis6270.data``. Prints the total correlation of each round, the Hamming distance and the
16
+ one-step success rate, and the AReUReDi proposal with its acceptance ratio.
17
+
18
+ python lecture_5/rectify.py # four rounds, 600 quadrature points each
19
+ python lecture_5/rectify.py --rounds 6 # closer to the limiting coupling
20
+ python lecture_5/rectify.py --steps 20 --quiet # a coarse integration, visibly less accurate
21
+ """
22
+
23
+ # %% 01. Imports and configuration
24
+ import math
25
+
26
+ import torch
27
+
28
+ from cis6270.data import DNA, DNA_SIZE, dna_splits
29
+ from cis6270.runner import common_parser, print_report, save_run, set_seed
30
+
31
+
32
+ torch.set_default_dtype(torch.double) # The recoupling is an exact integration, so keep precision.
33
+ LENGTH = 2 # Two positions, so the state space has 16 sequences and can be enumerated.
34
+ STATES = DNA_SIZE ** LENGTH
35
+ LETTER = torch.stack([ # [L, 16] the letter each sequence index holds at each position.
36
+ torch.arange(STATES) // DNA_SIZE, torch.arange(STATES) % DNA_SIZE,
37
+ ])
38
+
39
+
40
+ # %% 02. The pairing of source and data
41
+ #
42
+ # Since the first Euler step the joint distribution of the next state has been replaced by the
43
+ # product of the per-position marginals. With small steps that costs almost nothing. One large step
44
+ # draws every position at once from a product, and the divergence between the joint and that product
45
+ # is the conditional total correlation of the coupling.
46
+
47
+
48
+ def position_marginals(row):
49
+ """The per-position marginals of one distribution over sequences. row: [16] -> [L, K]."""
50
+ out = torch.zeros(LENGTH, DNA_SIZE)
51
+ for i in range(LENGTH):
52
+ out[i].index_add_(0, LETTER[i], row) # Collect the mass of every sequence by its letter.
53
+ return out
54
+
55
+
56
+ def total_correlation(coupling, source):
57
+ """TC_pi(X_1 | X_0), the expected divergence from the joint to the product of its marginals."""
58
+ total = 0.0
59
+ for s in range(STATES):
60
+ marginal = position_marginals(coupling[s]) # [L, K] given this source sequence.
61
+ product = marginal[0][LETTER[0]] * marginal[1][LETTER[1]] # [16] the factorized draw.
62
+ live = coupling[s] > 0
63
+ total += float(source[s] * (coupling[s][live] * (coupling[s][live] / product[live]).log()).sum())
64
+ return total # Zero exactly when the positions are independent given the source.
65
+
66
+
67
+ def one_step_success(coupling, source, support):
68
+ """The chance a single factorized draw lands inside the data, averaged over source sequences."""
69
+ total = 0.0
70
+ for s in range(STATES):
71
+ marginal = position_marginals(coupling[s])
72
+ total += float(source[s] * sum(marginal[0][a] * marginal[1][b] for a, b in support))
73
+ return total # 1/4 under the independent coupling on the complementary pairs.
74
+
75
+
76
+ def hamming(coupling, source):
77
+ """The expected number of positions at which a source and its paired data sequence differ."""
78
+ distance = (LETTER[:, :, None] != LETTER[:, None, :]).sum(dim=0).to(coupling) # [16, 16].
79
+ return float((source[:, None] * coupling * distance).sum()) # 1.5 under independent pairing.
80
+
81
+
82
+ # %% 03. Total correlation on two bases
83
+ #
84
+ # Let the data be the four complementary pairs, each with probability one quarter, and the source be
85
+ # uniform over all sixteen ordered pairs. Each position of the data alone is uniform over the four
86
+ # bases, so the product of the two marginals spreads one sixteenth everywhere while the joint sits on
87
+ # four states with one quarter apiece.
88
+
89
+
90
+ def complementary_pairs():
91
+ """The data distribution over AT, TA, CG and GC, and the indices of those four sequences."""
92
+ support = [(DNA.index(a), DNA.index(b)) for a, b in ("AT", "TA", "CG", "GC")]
93
+ q = torch.zeros(STATES)
94
+ for a, b in support:
95
+ q[a * DNA_SIZE + b] = 0.25 # Each complementary pair with probability one quarter.
96
+ return q, support
97
+
98
+
99
+ def adjacent_pairs(tokens):
100
+ """The empirical distribution of adjacent base pairs in the training DNA. Returns [16]."""
101
+ left, right = tokens[:, :-1].reshape(-1), tokens[:, 1:].reshape(-1) # Every adjacent pair.
102
+ counts = torch.bincount(left * DNA_SIZE + right, minlength=STATES).to(torch.get_default_dtype())
103
+ return counts / counts.sum() # [16] over the sixteen ordered pairs.
104
+
105
+
106
+ # %% 04. ReDi: one recoupling round
107
+ #
108
+ # The model here is the entire many-step sampler, and the next coupling pairs a source sample with
109
+ # the sequence the sampler produces from it. The velocity is the true marginal velocity of the
110
+ # current coupling, and the master equation is integrated without discretization error, so the only
111
+ # approximation left is the factorization across the two positions.
112
+
113
+
114
+ def generator(joint, t):
115
+ """The factorized velocity at time t, in the variable u with t = 1 - exp(-u). Returns [16, 16]."""
116
+ eye = torch.eye(DNA_SIZE)
117
+ mix = (1 - t) * eye[:, :, None] + t * eye[:, None, :] # [K, K, K] the mixture path at one position.
118
+ per = [mix[:, LETTER[i][:, None], LETTER[i][None, :]] for i in range(LENGTH)] # [K, 16, 16] each.
119
+ weight = joint[None, None, :, :] * per[0][:, None, :, :] * per[1][None, :, :, :]
120
+ weight = weight.reshape(STATES, STATES, STATES) # [x, x_0, x_1] the unnormalized posterior.
121
+ norm = weight.sum(dim=(1, 2)).clamp(min=1e-300)
122
+ posterior = weight.sum(dim=1) / norm[:, None] # [x, x_1] the posterior over the data sequence.
123
+ rates, columns = torch.zeros(STATES, STATES), torch.arange(STATES)
124
+ for i in range(LENGTH):
125
+ denoiser = torch.zeros(STATES, DNA_SIZE) # [x, a] the denoiser at position i.
126
+ denoiser.index_add_(1, LETTER[i], posterior)
127
+ for a in range(DNA_SIZE):
128
+ moved = LETTER.clone()
129
+ moved[i] = a # [L, 16] every sequence with position i replaced by the letter a.
130
+ destination = moved[0] * DNA_SIZE + moved[1] # [16] the index of that sequence.
131
+ keep = LETTER[i] != a # A sequence already holding a at position i sends nothing.
132
+ rates[destination[keep], columns[keep]] += denoiser[keep, a]
133
+ return rates - torch.diag(rates.sum(dim=0)) # In u-time the schedule factor is one.
134
+
135
+
136
+ def recouple(coupling, source, steps, horizon=14.0):
137
+ """Run the exact sampler from every source sequence and return the coupling it produces.
138
+
139
+ The substitution t = 1 - exp(-u) holds the schedule factor at one, so the integration stays
140
+ accurate as t approaches the horizon, where 1 - t is about 1e-6. Each cell takes a Heun step,
141
+ which is second order and reaches the chapter's figures at a few hundred cells.
142
+ """
143
+ joint = source[:, None] * coupling # pi(x_0, x_1) over pairs.
144
+ probability = torch.eye(STATES) # Row s is the state distribution started at the source s.
145
+ du = horizon / steps
146
+ for k in range(steps):
147
+ left = generator(joint, 1.0 - math.exp(-k * du)) # The velocity at the start of the cell.
148
+ right = generator(joint, 1.0 - math.exp(-(k + 1) * du)) # And at its end.
149
+ first = probability @ left.T # The Euler slope.
150
+ second = (probability + du * first) @ right.T # The slope the Euler step lands on.
151
+ probability = probability + 0.5 * du * (first + second) # Their average.
152
+ probability = probability.clamp(min=0.0)
153
+ probability = probability / probability.sum(dim=1, keepdim=True)
154
+ return probability # [16, 16] the next coupling, one row per source sequence.
155
+
156
+
157
+ # %% 05. AReUReDi: the Tchebycheff score and the tilt
158
+ #
159
+ # Scoring a sequence by its weakest weighted objective stops the sampler from trading one property
160
+ # away. The target is the base model times the exponentiated score, and the refinement anneals the
161
+ # strength linearly over its iterations, so early ones explore and late ones commit.
162
+
163
+
164
+ def tchebycheff(scores, weights):
165
+ """S_omega(x) = min_n omega_n s_n(x), the smallest weighted objective. scores: [M, N] -> [M]."""
166
+ return (scores * weights).min(dim=-1).values # Raising a weight lowers the pressure on it.
167
+
168
+
169
+ def anneal(iteration, total, low=1.0, high=20.0):
170
+ """eta_t = eta_min + (eta_max - eta_min) t/(T - 1), which is 1 + t at the chapter's settings."""
171
+ return low + (high - low) * iteration / (total - 1) # 12 at the twelfth iteration of twenty.
172
+
173
+
174
+ # %% 06. The proposal and its acceptance step
175
+ #
176
+ # The proposal starts from the model's own per-position distribution and reweights it by a balancing
177
+ # function of how much the tilt would change. The identity g(u) = u g(1/u) collapses the acceptance
178
+ # ratio to a ratio of two one-position normalizers, which costs one sum over the alphabet.
179
+
180
+
181
+ def barker(u):
182
+ """g(u) = u/(1 + u), which satisfies g(u) = u g(1/u)."""
183
+ return u / (1.0 + u) # The square root is the other common choice.
184
+
185
+
186
+ def proposal(model_distribution, tilts, current):
187
+ """q_i(. | x) and its normalizer Z(x). tilts: [K] the value of W at each letter."""
188
+ ratios = tilts / tilts[current] # r_i(y; x), the tilt at the proposed letter over the current one.
189
+ weighted = model_distribution * barker(ratios) # The model's prior times the balancing function.
190
+ return weighted / weighted.sum(), float(weighted.sum()), ratios # q, Z(x) and the ratios.
191
+
192
+
193
+ def acceptance(z_current, z_proposed):
194
+ """alpha_i(x, x') = min(1, Z(x)/Z(x')), what the balancing identity leaves of the ratio."""
195
+ return min(1.0, z_current / z_proposed) # One in both directions when the normalizers agree.
196
+
197
+
198
+ # %% 07. Concentration on the maximizers
199
+ #
200
+ # Keeping only the maximizers in the denominator and bounding the numerator by the score gap gives an
201
+ # exponential bound on the probability of anything outside the maximizing set, so the samples
202
+ # concentrate there as the strength grows.
203
+
204
+
205
+ def tilted_distribution(base, scores, eta):
206
+ """pi_{eta, omega}(x) proportional to p_1(x) exp(eta S_omega(x))."""
207
+ weighted = base * (eta * scores).exp()
208
+ return weighted / weighted.sum() # [M] over the sequences under consideration.
209
+
210
+
211
+ def concentration_bound(base, scores, eta):
212
+ """exp(-eta Delta) p_1(x) over the base mass of the maximizers, for a sequence outside the set."""
213
+ best = scores.max()
214
+ gap = float(best - scores[scores < best].max()) # Delta_omega, the gap to the next best score.
215
+ return math.exp(-eta * gap) / float(base[scores == best].sum()), gap
216
+
217
+
218
+ # %% 08. The report
219
+ def main():
220
+ parser = common_parser(__doc__.split("\n")[0])
221
+ parser.add_argument("--rounds", type=int, default=4, help="rectification rounds")
222
+ args = parser.parse_args()
223
+ set_seed(args.seed)
224
+
225
+ source = torch.full((STATES,), 1.0 / STATES) # Uniform over all sixteen ordered pairs.
226
+ q, support = complementary_pairs()
227
+ coupling = q[None, :].repeat(STATES, 1) # The independent coupling pairs every source with q.
228
+ report = {
229
+ "tc_round_0": round(total_correlation(coupling, source), 3), # log 4 = 1.386.
230
+ "log_4": round(math.log(4), 3), # What the enumeration of the four pairs gives.
231
+ "hamming_round_0": round(hamming(coupling, source), 3), # 1.5.
232
+ "success_round_0": round(one_step_success(coupling, source, support), 4), # 0.25.
233
+ }
234
+ for round_index in range(1, args.rounds + 1):
235
+ coupling = recouple(coupling, source, args.steps) # One application of the operator.
236
+ report[f"tc_round_{round_index}"] = round(total_correlation(coupling, source), 3)
237
+ report[f"hamming_round_{round_index}"] = round(hamming(coupling, source), 3)
238
+ report[f"success_round_{round_index}"] = round(one_step_success(coupling, source, support), 4)
239
+ if round_index == 1: # Where the first round's gain comes from, read at two sources.
240
+ columns = [a * DNA_SIZE + b for a, b in support] # The four complementary pairs.
241
+ for name in ("AA", "AT"): # One source outside the data and one inside it.
242
+ row = DNA.index(name[0]) * DNA_SIZE + DNA.index(name[1])
243
+ report[f"pairing_from_{name}"] = [round(float(coupling[row, c]), 3) for c in columns]
244
+ report["tc_limit"] = round(0.75 * math.log(2), 3) # Twelve sources split in half, 0.520 nats.
245
+ report["success_limit"] = round(10.0 / 16.0, 4) # 0.625, four exact sources and twelve halves.
246
+ report["hamming_limit"] = round(12.0 / 16.0, 4) # 0.75, the least any coupling can reach.
247
+
248
+ # The same measurement on adjacent base pairs of the synthetic DNA, under independent pairing.
249
+ splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
250
+ dna = adjacent_pairs(splits["train_tokens"]) # [16] the empirical distribution of a base pair.
251
+ dna_coupling = dna[None, :].repeat(STATES, 1) # Every source paired with the same distribution.
252
+ live = dna[dna > 0]
253
+ report["dna_pair_entropy"] = round(float(-(live * live.log()).sum()), 4) # At most log 16 = 2.77.
254
+ report["dna_total_correlation"] = round(total_correlation(dna_coupling, source), 4)
255
+ report["dna_fraction_of_entropy"] = round(
256
+ total_correlation(dna_coupling, source) / float(-(live * live.log()).sum()), 4)
257
+
258
+ # Section 5.41.2: one refinement iteration at the twelfth of twenty, with two objectives.
259
+ scores = torch.tensor([[0.60, 0.80], [0.70, 0.76], [0.50, 0.90], [0.64, 0.62]])
260
+ weights = torch.tensor([0.5, 0.5])
261
+ eta = anneal(11, 20) # The twelfth iteration, counting from zero, where 1 + t equals 12.
262
+ tchebycheff_scores = tchebycheff(scores, weights)
263
+ tilts = (eta * tchebycheff_scores).exp() # W = exp(eta S) at that strength.
264
+ report["annealed_eta"] = round(eta, 4) # 1 + t over twenty iterations.
265
+ report["tchebycheff"] = [round(float(s), 3) for s in tchebycheff_scores] # (0.300, ...).
266
+ report["tilts"] = [round(float(w), 2) for w in tilts] # (36.60, 66.69, 20.09, 41.26).
267
+ model_distribution = torch.tensor([0.40, 0.25, 0.20, 0.15]) # The rectified model at this position.
268
+ q_current, z_current, ratios = proposal(model_distribution, tilts, 0) # The position holds A.
269
+ report["ratios_from_A"] = [round(float(r), 3) for r in ratios] # (1.000, 1.822, 0.549, 1.127).
270
+ report["barker_from_A"] = [round(float(barker(r)), 3) for r in ratios] # (0.500, 0.646, ...).
271
+ report["normalizer_at_A"] = round(z_current, 4) # 0.5118.
272
+ report["proposal_at_A"] = [round(float(p), 3) for p in q_current] # (0.391, 0.315, 0.138, 0.155).
273
+ _, z_proposed, _ = proposal(model_distribution, tilts, 1) # The proposed sequence holds C.
274
+ report["normalizer_at_C"] = round(z_proposed, 4) # 0.3704.
275
+ report["accept_A_to_C"] = round(acceptance(z_current, z_proposed), 3) # One.
276
+ report["accept_C_to_A"] = round(acceptance(z_proposed, z_current), 3) # 0.724.
277
+
278
+ # Section 5.41.3: the tilted distribution over four sequences, and the bound on a non-maximizer.
279
+ base = torch.tensor([0.50, 0.25, 0.15, 0.10])
280
+ objective = torch.tensor([0.20, 0.30, 0.45, 0.45])
281
+ for strength in (20.0, 60.0):
282
+ tilted = tilted_distribution(base, objective, strength)
283
+ bound, gap = concentration_bound(base, objective, strength)
284
+ report[f"mass_on_maximizers_eta_{strength:g}"] = f"{float(tilted[2:].sum()):.6f}"
285
+ report[f"second_sequence_eta_{strength:g}"] = f"{float(tilted[1]):.6f}"
286
+ report[f"bound_eta_{strength:g}"] = f"{bound * float(base[1]):.6f}"
287
+ report["score_gap"] = round(gap, 4) # 0.15.
288
+ print_report("rectification", report)
289
+ save_run(args.out, config=vars(args), report=report)
290
+
291
+
292
+ if __name__ == "__main__":
293
+ main()