Download cis6270/data.py from ChatterjeeLab/CIS6270: direct link, hf CLI and curl.
- Browser
- Download file 15.6 kB
-
https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/cis6270/data.py
- Command line
-
hf download hf://ChatterjeeLab/CIS6270/cis6270/data.py
-
curl -L -o data.py https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/cis6270/data.py
15.6 kB
| """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 | |