File size: 15,609 Bytes
0ba9d09
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
"""Datasets for the course.

Everything here runs offline in under a second, so a fresh clone and the test suite run without a
network. MNIST and ESM-2 embeddings download on first use, and only the scripts with a
``--dataset`` flag reach them.

The discrete lectures all run on the same synthetic DNA, so a loss from Lecture 4 and a loss from
Lecture 5 are comparable.
"""

from __future__ import annotations

from pathlib import Path

import torch

# %% 01. The DNA alphabet
#
# The vocabulary is the four DNA letters, because Chapters 4 and 5 run every worked example on
# DNA. A lecture that needs a mask token appends it as index 4 in its own file, since masked
# diffusion has a mask and uniform diffusion does not.

DNA = "ACGT"  # Token 0 is A, 1 is C, 2 is G, 3 is T.
DNA_SIZE = len(DNA)  # K = 4, the alphabet size the chapters call |V|.
MOTIF = "ACGT"  # The planted motif, used as the label in the guidance examples.


def encode_dna(sequences: list[str]) -> torch.Tensor:
    """Turn a list of strings over ACGT into a long tensor of shape [N, L]."""
    index = {letter: i for i, letter in enumerate(DNA)}  # Letter to token id.
    rows = [[index[letter] for letter in sequence] for sequence in sequences]
    return torch.tensor(rows, dtype=torch.long)  # [N, L] token ids.


def decode_dna(tokens: torch.Tensor) -> list[str]:
    """Turn a long tensor of shape [N, L] back into strings over ACGT."""
    return ["".join(DNA[int(i)] for i in row) for row in tokens]  # One string per row.


# %% 02. Synthetic DNA
#
# We plant two pieces of structure in these sequences: a GC-rich background, and the motif ACGT
# at a random position in a fraction of them. If we trained on uniform random sequences the
# sampler would look the same before and after. The GC fraction gives the label for the
# conditional examples, and the motif gives the property to steer toward.


def synthetic_dna(
    num_sequences: int = 512,
    length: int = 16,
    motif_fraction: float = 0.75,
    gc_bias: float = 0.6,
    seed: int = 0,
) -> tuple[torch.Tensor, torch.Tensor]:
    """Draw synthetic DNA with a planted motif.

    Returns the tokens as a long tensor of shape [N, L] and a binary label of shape [N] that is
    one when the sequence has GC fraction at least 0.6. The label is a deterministic function of
    the sequence, with no biological meaning.
    """
    generator = torch.Generator().manual_seed(seed)  # Local generator; global seed untouched.

    # Background letters: C and G each get gc_bias/2, A and T share the rest.
    background = torch.tensor([
        (1.0 - gc_bias) / 2.0,  # A
        gc_bias / 2.0,  # C
        gc_bias / 2.0,  # G
        (1.0 - gc_bias) / 2.0,  # T
    ])
    tokens = torch.multinomial(
        background.expand(num_sequences * length, DNA_SIZE),  # One draw per position.
        num_samples=1,
        replacement=True,
        generator=generator,
    ).view(num_sequences, length)  # [N, L] background sequence.

    # Plant the motif in a fraction of the sequences, at a position drawn uniformly.
    motif = encode_dna([MOTIF])[0]  # [4] token ids of ACGT.
    carries_motif = torch.rand(num_sequences, generator=generator) < motif_fraction  # [N] bool.
    # The motif starts at one of three places, so a small model can learn the pattern from a few
    # hundred sequences while the position still varies. We keep a start only when the whole motif
    # fits, so a short sequence has fewer of them.
    room = length - len(MOTIF)  # Last index at which the motif still fits.
    choices = torch.tensor([s for s in (2, 6, 10) if s <= room] or [0])
    starts = choices[torch.randint(0, len(choices), (num_sequences,), generator=generator)]
    for row in torch.nonzero(carries_motif).flatten().tolist():
        start = int(starts[row])
        tokens[row, start:start + len(MOTIF)] = motif  # Overwrite those positions.

    gc = ((tokens == 1) | (tokens == 2)).to(torch.get_default_dtype()).mean(dim=1)  # [N] fraction of C and G.
    labels = (gc >= 0.6).long()  # [N] the toy label used by the conditional examples.
    return tokens, labels


def dna_splits(
    num_sequences: int = 512, length: int = 16, seed: int = 0, validation: float = 0.2,
) -> dict[str, torch.Tensor]:
    """The same synthetic DNA, split into a training and a validation set."""
    tokens, labels = synthetic_dna(num_sequences, length, seed=seed)
    cut = int(num_sequences * (1.0 - validation))  # Index where validation begins.
    return {
        "train_tokens": tokens[:cut], "train_labels": labels[:cut],
        "valid_tokens": tokens[cut:], "valid_labels": labels[cut:],
    }


def write_dna_tsv(path: str | Path, tokens: torch.Tensor, labels: torch.Tensor) -> None:
    """Write sequences and labels as a two-column TSV, the format ``--data`` reads."""
    lines = ["sequence\tlabel"]
    lines += [f"{s}\t{int(y)}" for s, y in zip(decode_dna(tokens), labels)]
    Path(path).write_text("\n".join(lines) + "\n")


def read_dna_tsv(path: str | Path) -> tuple[torch.Tensor, torch.Tensor]:
    """Read a two-column TSV of sequences and labels."""
    rows = Path(path).read_text().strip().split("\n")[1:]  # Skip the header line.
    sequences = [row.split("\t")[0] for row in rows]
    labels = [int(row.split("\t")[1]) for row in rows]
    return encode_dna(sequences), torch.tensor(labels, dtype=torch.long)


# %% 03. Two-dimensional densities
#
# The continuous lectures run on four Gaussians at the corners of a square, a target that plots in
# two dimensions and has an exact density. The modes are far enough apart that a collapsed sampler
# is obvious. The log density has a closed form the reports can quote.

GAUSSIAN_CENTERS = torch.tensor([[2.0, 2.0], [2.0, -2.0], [-2.0, 2.0], [-2.0, -2.0]])
GAUSSIAN_STD = 0.35  # Standard deviation of each component.


def gaussian_mixture_2d(num_points: int = 2048, seed: int = 0) -> torch.Tensor:
    """Draw from four equally weighted Gaussians at the corners of a square. Returns [N, 2]."""
    generator = torch.Generator().manual_seed(seed)
    which = torch.randint(0, len(GAUSSIAN_CENTERS), (num_points,), generator=generator)  # [N].
    noise = torch.randn(num_points, 2, generator=generator) * GAUSSIAN_STD  # [N, 2].
    return GAUSSIAN_CENTERS[which] + noise  # [N, 2] points.


def gaussian_mixture_log_density(points: torch.Tensor) -> torch.Tensor:
    """Exact log density of the four-Gaussian mixture at each row of ``points`` ([N, 2] -> [N])."""
    diff = points[:, None, :] - GAUSSIAN_CENTERS.to(points)  # [N, 4, 2] offset to each center.
    squared = (diff ** 2).sum(dim=-1)  # [N, 4] squared distance to each center.
    log_component = (
        -0.5 * squared / GAUSSIAN_STD ** 2  # Gaussian exponent.
        - torch.log(torch.tensor(2.0 * torch.pi * GAUSSIAN_STD ** 2))  # Normalizer in 2-D.
    )  # [N, 4].
    weight = torch.log(torch.tensor(1.0 / len(GAUSSIAN_CENTERS)))  # Equal mixture weights.
    return torch.logsumexp(log_component + weight, dim=-1)  # [N] mixture log density.


def two_moons(num_points: int = 2048, noise: float = 0.08, seed: int = 0) -> torch.Tensor:
    """Two interleaving half circles, the standard test for a sampler that must bend. [N, 2]."""
    generator = torch.Generator().manual_seed(seed)
    half = num_points // 2
    angle = torch.rand(half, generator=generator) * torch.pi  # [half] angle along each arc.
    upper = torch.stack([torch.cos(angle), torch.sin(angle)], dim=1)  # Upper half circle.
    lower = torch.stack([1.0 - torch.cos(angle), 0.5 - torch.sin(angle)], dim=1)  # Lower arc.
    points = torch.cat([upper, lower], dim=0)  # [2*half, 2].
    points = points + noise * torch.randn(points.shape, generator=generator)  # Blur the arcs.
    return (points - points.mean(0)) / points.std(0)  # Standardize to roughly unit scale.


def checkerboard(num_points: int = 2048, seed: int = 0) -> torch.Tensor:
    """A checkerboard of eight squares, a density with disconnected support. [N, 2]."""
    generator = torch.Generator().manual_seed(seed)
    x = torch.rand(num_points, generator=generator) * 4.0 - 2.0  # [N] in [-2, 2].
    y = torch.rand(num_points, generator=generator) - torch.randint(
        0, 2, (num_points,), generator=generator,
    ).to(torch.get_default_dtype()) * 2.0  # [N] in [-2, 1] before the shift below.
    y = y + torch.floor(x) % 2.0  # Shift alternate columns so the squares interleave.
    return torch.stack([x, y * 2.0], dim=1)  # [N, 2] points.


# %% 04. Short phrases
#
# Chapter 6 runs its categorical flow maps on four-token phrases. The grammar is small enough that
# a reader can tell at a glance whether a generated phrase is in the language.

_SUBJECTS = ["red", "blue", "green", "small"]
_OBJECTS = ["circle", "square", "arrow", "dot"]
_VERBS = ["moves", "turns", "drifts", "jumps"]
_DIRECTIONS = ["left", "right", "up", "down"]


def phrase_vocabulary() -> list[str]:
    """The token vocabulary of the phrase language, in a fixed order."""
    return _SUBJECTS + _OBJECTS + _VERBS + _DIRECTIONS  # 16 tokens, four per slot.


def phrases(num_phrases: int = 512, seed: int = 0) -> tuple[torch.Tensor, list[str]]:
    """Draw four-token phrases such as ``red circle moves left``. Returns [N, 4] and the vocabulary.

    Each slot draws from its own quarter of the vocabulary, so a correct phrase is one whose
    position i holds a token from group i. The training support is then easy to check.
    """
    generator = torch.Generator().manual_seed(seed)
    vocabulary = phrase_vocabulary()
    groups = [_SUBJECTS, _OBJECTS, _VERBS, _DIRECTIONS]
    columns = []
    for slot, group in enumerate(groups):
        offset = slot * len(group)  # Where this slot's tokens start in the vocabulary.
        picks = torch.randint(0, len(group), (num_phrases,), generator=generator)
        columns.append(picks + offset)  # [N] token ids inside the full vocabulary.
    return torch.stack(columns, dim=1), vocabulary  # [N, 4] and the 16 token strings.


# %% 05. MNIST and ESM-2
#
# These two download only when a script runs with --dataset mnist or --dataset esm2, and every
# such script also runs on the offline data above.


def mnist_loader(batch_size: int = 128, root: str | Path = "data", train: bool = True):
    """MNIST as a DataLoader of images normalized to [-1, 1]. Downloads on first use."""
    from torch.utils.data import DataLoader  # Imported here so the module loads without torchvision.
    from torchvision import datasets
    from torchvision.transforms import v2

    transform = v2.Compose([
        v2.ToImage(),  # Channel-first image: [1, 28, 28].
        v2.ToDtype(torch.float32, scale=True),  # Integer pixels 0-255 to floats in [0, 1].
        v2.Normalize(mean=(0.5,), std=(0.5,)),  # Then [0, 1] to [-1, 1].
    ])
    dataset = datasets.MNIST(root=root, train=train, download=True, transform=transform)
    return DataLoader(dataset, batch_size=batch_size, shuffle=train, num_workers=0)


def toy_images(num_images: int = 256, size: int = 28, seed: int = 0) -> torch.Tensor:
    """Images shaped like MNIST, drawn from a two-mode distribution of bars and blobs.

    These stand in for MNIST wherever a test has to run without a download. They have the same
    shape and the same [-1, 1] range, so the identical training and sampling code paths run.
    """
    generator = torch.Generator().manual_seed(seed)
    images = torch.full((num_images, 1, size, size), -1.0)  # Background at the low end of [-1, 1].
    grid = torch.arange(size)
    for i in range(num_images):
        if i % 2 == 0:  # A vertical bar.
            column = int(torch.randint(6, size - 6, (1,), generator=generator))
            images[i, 0, 4:size - 4, column - 2:column + 2] = 1.0
        else:  # A round blob.
            cy = int(torch.randint(8, size - 8, (1,), generator=generator))
            cx = int(torch.randint(8, size - 8, (1,), generator=generator))
            dy = (grid[:, None] - cy) ** 2
            dx = (grid[None, :] - cx) ** 2
            images[i, 0][(dy + dx) <= 25] = 1.0
    images = images + 0.05 * torch.randn(images.shape, generator=generator)  # Light pixel noise.
    return images.clamp(-1.0, 1.0)  # [N, 1, size, size] in the training range.


PEPTIDES = [
    "KWKLFKKIGAVLKVL", "GIGKFLHSAKKFGKA", "FLPIIAKLLSGLL", "ILPWKWPWWPWRR",
    "KRWWKWWRR", "GLLSKLWKKVFKAFKKFLKK", "ACDEFGHIKLMNPQR", "RRWWRRWRR",
    "LLGDFFRKSKEKIGKE", "KIAKVALKAL", "VRRFPWWWPFLRR", "GWLKKIGKKIERVGQ",
]  # Short antimicrobial-peptide-like sequences, used when ESM-2 is unavailable.


def esm2_embeddings(
    sequences: list[str] | None = None,
    model_name: str = "facebook/esm2_t33_650M_UR50D",
    max_length: int = 32,
    device: torch.device | str = "cpu",
):
    """Residue embeddings from a frozen ESM-2 encoder. Downloads the checkpoint on first use.

    Returns the embeddings [N, L, D], the attention mask [N, L], the tokenizer and the masked
    language-model head that decodes generated latents back to amino acids in Lecture 3.
    """
    from transformers import AutoModel, AutoModelForMaskedLM, AutoTokenizer

    sequences = sequences or PEPTIDES
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    encoder = AutoModel.from_pretrained(model_name).to(device).eval()
    head = AutoModelForMaskedLM.from_pretrained(model_name).lm_head.to(device).eval()
    for parameter in encoder.parameters():
        parameter.requires_grad = False  # The encoder is frozen; only the denoiser trains.

    batch = tokenizer(
        sequences, padding="max_length", truncation=True,
        max_length=max_length, return_tensors="pt",
    ).to(device)
    with torch.no_grad():
        embeddings = encoder(**batch).last_hidden_state  # [N, L, D] residue embeddings.
    return embeddings, batch["attention_mask"], tokenizer, head


def toy_embeddings(
    num_sequences: int = 128, length: int = 16, width: int = 32, seed: int = 0,
) -> tuple[torch.Tensor, torch.Tensor]:
    """Embeddings shaped like ESM-2 output, for the offline path of the latent examples.

    Each sequence is a smooth low-frequency curve along the residue axis plus noise, so neighbouring
    positions are correlated the way real residue embeddings are. Returns [N, L, D] and a mask.
    """
    generator = torch.Generator().manual_seed(seed)
    phase = torch.rand(num_sequences, 1, width, generator=generator) * 2.0 * torch.pi
    frequency = torch.rand(num_sequences, 1, width, generator=generator) * 0.6 + 0.1
    position = torch.arange(length).view(1, length, 1).to(torch.get_default_dtype())  # [1, L, 1].
    signal = torch.sin(frequency * position + phase)  # [N, L, D] smooth along the residue axis.
    noise = 0.2 * torch.randn(num_sequences, length, width, generator=generator)
    mask = torch.ones(num_sequences, length)  # No padding in the toy set.
    return signal + noise, mask


def standardize(embeddings: torch.Tensor, mask: torch.Tensor):
    """Center and scale each embedding coordinate over the valid residues.

    Returns the standardized embeddings and the mean and standard deviation, used to undo the
    transformation before the decoder reads amino acids.
    """
    valid = mask.bool().unsqueeze(-1)  # [N, L, 1] true at a real residue.
    count = valid.sum()  # Number of valid residue-coordinate entries.
    mean = (embeddings * valid).sum(dim=(0, 1)) / count  # [D] per-coordinate mean.
    variance = ((embeddings - mean) ** 2 * valid).sum(dim=(0, 1)) / count  # [D].
    std = variance.sqrt().clamp(min=1e-6)  # [D], clamped so a dead coordinate cannot divide by zero.
    return (embeddings - mean) / std, mean, std