pranamanam commited on
Commit
e2e7e18
·
verified ·
1 Parent(s): 4540679

Delete cis6270

Browse files
Files changed (5) hide show
  1. cis6270/__init__.py +0 -20
  2. cis6270/data.py +0 -317
  3. cis6270/metrics.py +0 -100
  4. cis6270/nets.py +0 -231
  5. cis6270/runner.py +0 -173
cis6270/__init__.py DELETED
@@ -1,20 +0,0 @@
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 DELETED
@@ -1,317 +0,0 @@
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 DELETED
@@ -1,100 +0,0 @@
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 DELETED
@@ -1,231 +0,0 @@
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 DELETED
@@ -1,173 +0,0 @@
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}")