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
|