Download lecture_4/search.py from ChatterjeeLab/CIS6270: direct link, hf CLI and curl.
- Browser
- Download file 14.8 kB
-
https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_4/search.py
- Command line
-
hf download hf://ChatterjeeLab/CIS6270/lecture_4/search.py
-
curl -L -o search.py https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_4/search.py
14.8 kB
| """Guiding masked diffusion on DNA by search over the tree of partial sequences. | |
| For a property computed by an external scorer we have no classifier at intermediate noise levels, | |
| so we estimate the value function by finishing the sequence and scoring the result. The partial | |
| sequences form a tree whose edges are single reveals. Selection walks it by an upper-confidence | |
| rule, a rollout scores a new leaf, and the reward propagates back up the branch. No single number | |
| orders the two objectives, so we keep them in a Pareto archive. | |
| Chapter 4, "Guiding generation by search" and "Search over the tree of partial sequences". The | |
| fine-tuning of "Keeping the results of the search" is left to the chapter. Time runs from t = 0 | |
| clean to t = 1 all masks, the survival probability is alpha_t = 1 - t, and the rates R_t(x, y) | |
| act on probability rows, p_dot = p R. | |
| Trained on the synthetic DNA of cis6270.data. Prints the worked value estimate, the worked archive, | |
| and the archive the search finds. | |
| python lecture_4/search.py # train, then search | |
| python lecture_4/search.py --searches 64 # a larger tree | |
| python lecture_4/search.py --sample-steps 8 # longer rollouts behind every value | |
| python lecture_4/search.py --steps 20 # a few seconds, for a first look | |
| """ | |
| # %% 01. Imports and configuration | |
| import math # The upper-confidence rule is a square root of a logarithm. | |
| import torch # Tensors, gradients and the categorical draws. | |
| import torch.nn.functional as F # Cross-entropy at every position. | |
| from cis6270.data import DNA_SIZE, MOTIF, decode_dna, dna_splits, encode_dna | |
| from cis6270.metrics import gc_fraction, motif_fraction, unique_fraction | |
| from cis6270.nets import SequenceDenoiser, count_parameters # The denoiser d_theta(. | z, t). | |
| from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train | |
| MASK = DNA_SIZE # A, C, G, T are 0 to 3, and token 4 is the mask m. | |
| VOCAB = DNA_SIZE + 1 # The masked alphabet V_m has K + 1 = 5 symbols. | |
| TIME_FLOOR = 1e-3 # The reveal rate w(t) = 1/t is unbounded at t = 0. | |
| EXPLORATION = 1.0 # c_uct, the weight on the visit-count term of the selection rule. | |
| # %% 02. The two toy objectives | |
| # | |
| # Binding, solubility and permeability do not reduce to one number without a weighting that fixes | |
| # the trade-off in advance. Two scores stand in for them here, a GC fraction and the best partial | |
| # match to the planted motif, both in [0, 1] and neither implying the other. | |
| def objectives(tokens: torch.Tensor) -> torch.Tensor: | |
| """Returns [N, 2]: the GC fraction of each sequence and its best match to the motif.""" | |
| gc = ((tokens == 1) | (tokens == 2)).to(torch.get_default_dtype()).mean(dim=1) # [N] C and G. | |
| motif = encode_dna([MOTIF])[0].to(tokens.device) # [4] the planted motif ACGT. | |
| windows = tokens.unfold(1, len(motif), 1) # [N, L - 3, 4] every window of the motif's length. | |
| hits = (windows == motif).to(torch.get_default_dtype()).mean(dim=2) # [N, L - 3] per window. | |
| match = hits.max(dim=1).values # [N] the best window of each sequence. | |
| return torch.stack([gc, match], dim=1) # [N, 2] one row per sequence. | |
| # %% 03. Rollouts as a value estimate | |
| # | |
| # h_t(z) is the probability that a sequence continuing from z ends with the property, so we run | |
| # the sampler to completion N times and average the reward. That estimate is unbiased, with a | |
| # variance that falls like 1/N. | |
| # Rollouts need no gradient tracking. | |
| def complete(model: torch.nn.Module, z: torch.Tensor, t: float, steps: int = 3) -> torch.Tensor: | |
| """Run the masked sampler from z at time t down to clean DNA. z: [N, L] -> [N, L].""" | |
| device = z.device | |
| ts = torch.linspace(t, TIME_FLOOR, steps + 1, device=device) # The remaining reverse grid. | |
| for now, nxt in zip(ts[:-1], ts[1:]): | |
| logits = model(z, now.expand(len(z))) # [N, L, 5] the clean-token prediction. | |
| logits[..., MASK] = -1e9 # The zero-mask rule: no posterior mass belongs on m. | |
| x0 = torch.distributions.Categorical(logits=logits).sample() # [N, L]. | |
| unmask = torch.rand(z.shape, device=device) < (now - nxt) / now # [N, L] the reveal draw. | |
| z = torch.where((z == MASK) & unmask, x0, z) # Carry-over unmasking. | |
| logits = model(z, ts[-1].expand(len(z))) # The last step runs down to s = 0, where r = 1. | |
| logits[..., MASK] = -1e9 | |
| return torch.where(z == MASK, torch.distributions.Categorical(logits=logits).sample(), z) | |
| def value_estimate(model: torch.nn.Module, state: torch.Tensor, t: float, rollouts: int, | |
| steps: int = 3) -> tuple[torch.Tensor, torch.Tensor]: | |
| """Average the reward over N completions of one partial sequence. Returns [2] and [N, L].""" | |
| batch = state[None, :].repeat(rollouts, 1) # [N, L] the same state, N independent endings. | |
| finished = complete(model, batch, 1.0 if t >= 1.0 else t, steps) # [N, L] clean sequences. | |
| return objectives(finished).mean(dim=0), finished # [2] the value, and what produced it. | |
| # %% 04. The Pareto archive | |
| # | |
| # A candidate worse on every axis than one already kept is dominated and discarded, and the rest | |
| # stay, since each of them beats every other on at least one axis. | |
| class ParetoArchive: | |
| """Every nondominated sequence the search has produced, with its objective vector.""" | |
| def __init__(self) -> None: | |
| self.entries: list[tuple[tuple[int, ...], tuple[float, ...]]] = [] | |
| def add(self, sequence: tuple[int, ...], scores: tuple[float, ...]) -> None: | |
| """Keep the candidate when nothing in the archive beats it on every axis.""" | |
| for _, kept in self.entries: | |
| if all(k >= s for k, s in zip(kept, scores)) and kept != scores: | |
| return # Dominated, so it adds nothing. | |
| self.entries = [(q, kept) for q, kept in self.entries # Drop what the newcomer dominates. | |
| if not (all(s >= k for s, k in zip(scores, kept)) and kept != scores)] | |
| if sequence not in {q for q, _ in self.entries}: | |
| self.entries.append((sequence, scores)) | |
| # %% 05. The tree of partial sequences | |
| # | |
| # Selection walks from the root to a leaf by the upper-confidence rule, and expansion adds | |
| # children drawn from the denoiser perturbed by Gumbel noise. A rollout scores one of them, and | |
| # the reward propagates back up the branch so that every node on it accumulates visits. | |
| class Node: | |
| """A partially unmasked sequence, with the visits and rewards the search has accumulated.""" | |
| def __init__(self, state: torch.Tensor, depth: int, parent=None): | |
| self.state, self.depth, self.parent = state, depth, parent | |
| self.children: list[Node] = [] | |
| self.visits, self.total = 0, 0.0 # The visit count and the summed scalar reward. | |
| def mean(self) -> float: | |
| """The mean reward at this node, weighed by the selection rule against its visit count.""" | |
| return self.total / self.visits if self.visits else 0.0 | |
| def best_child(self): | |
| """argmax of r_bar_j + c sqrt(log n / n_j), the usual upper-confidence rule.""" | |
| return max(self.children, key=lambda child: child.mean + EXPLORATION * math.sqrt( | |
| math.log(max(self.visits, 1)) / child.visits) if child.visits else float("inf")) | |
| # The expansion needs no gradient tracking. | |
| def expand(model: torch.nn.Module, node: Node, t: float, branch: int) -> None: | |
| """Add children by revealing one masked position, drawn from the denoiser plus Gumbel noise.""" | |
| masked = (node.state == MASK).nonzero().flatten() # [m] the positions a reveal may act on. | |
| if len(masked) == 0: | |
| return # A complete sequence has no children. | |
| logits = model(node.state[None], torch.tensor([t], device=node.state.device))[0] # [L, 5]. | |
| logits[..., MASK] = -1e9 # The zero-mask rule. | |
| gumbel = -torch.log(-torch.log(torch.rand_like(logits).clamp(min=1e-20))) # [L, 5]. | |
| perturbed = (logits + gumbel).masked_fill( # Taking maxima of this samples the denoiser. | |
| (node.state != MASK)[:, None], float("-inf")) | |
| flat = perturbed[:, :DNA_SIZE].flatten() # [L K] one score per position and clean token. | |
| for index in flat.topk(min(branch, int(len(masked)) * DNA_SIZE)).indices: # Distinct reveals. | |
| child_state = node.state.clone() # [L] the parent with one more position filled. | |
| child_state[int(index) // DNA_SIZE] = int(index) % DNA_SIZE | |
| node.children.append(Node(child_state, node.depth + 1, node)) | |
| def backup(node: Node, reward: float) -> None: | |
| """Add one rollout's reward to every node on the branch from here to the root.""" | |
| while node is not None: | |
| node.visits, node.total = node.visits + 1, node.total + reward | |
| node = node.parent | |
| # %% 06. The report | |
| def main() -> None: | |
| parser = common_parser(__doc__.split("\n")[0]) | |
| parser.add_argument("--searches", type=int, default=24, help="tree expansions") | |
| parser.add_argument("--branch", type=int, default=3, help="children added at each expansion") | |
| parser.add_argument("--rollouts", type=int, default=6, help="completions scored per leaf") | |
| # Every leaf gets its own rollouts, so --sample-steps is the length of one rollout and stays | |
| # short. We train for 1000 steps, and that keeps the run inside about a minute. | |
| parser.set_defaults(steps=1000, sample_steps=3) | |
| args = parser.parse_args() | |
| set_seed(args.seed) | |
| device = pick_device(args.device) | |
| talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block. | |
| # Section 03, the worked value estimate and the reweighting it produces. | |
| at_parent = float(torch.tensor([0.1, 0.8, 0.3, 0.2]).mean()) # Four rollouts from z. | |
| at_child = float(torch.tensor([0.7, 0.9, 0.6, 0.8]).mean()) # Four rollouts from its child. | |
| factor = at_child / at_parent # The h-transform factor on that edge. | |
| sibling = 0.2 / at_parent # A child whose rollouts averaged 0.2 is slowed by this factor. | |
| talk(f"value estimates h(z) = {at_parent:.2f} h(z') = {at_child:.2f} factor " | |
| f"{factor:.3f} a model rate of 1.2 becomes {1.2 * factor:.3f}, and a sibling at 0.2 is " | |
| f"slowed by {sibling:.3f}") | |
| # Section 04, the worked archive on three candidates scored on two axes. | |
| worked = ParetoArchive() | |
| for name, scores in (("P1", (0.8, 0.3)), ("P2", (0.5, 0.7)), ("P3", (0.4, 0.2))): | |
| worked.add(tuple(ord(c) for c in name), scores) # The name stands in for a sequence. | |
| kept = [chr(q[0]) + chr(q[1]) for q, _ in worked.entries] # Which candidates survived. | |
| talk(f"worked archive keeps {kept}, so P3 is dominated by P1 on both axes") | |
| # Train the masked diffusion model the search will guide. | |
| splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed) | |
| train_tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT. | |
| model = SequenceDenoiser(VOCAB, args.length, width=args.width).to(device) | |
| talk(f"device={device} parameters={count_parameters(model):,} searches={args.searches}") | |
| def step_loss(step: int) -> torch.Tensor: | |
| """The masked diffusion loss, unchanged, since the search reweights rates when sampling.""" | |
| rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device) | |
| x0 = train_tokens[rows] | |
| t = torch.rand(len(x0), device=device).clamp(min=TIME_FLOOR) # [B] one noise level each. | |
| zt = torch.where(torch.rand(x0.shape, device=device) < (1.0 - t)[:, None], x0, MASK) | |
| logits = model(zt, t) # [B, L, 5]. | |
| logits[..., MASK] = -1e9 # The zero-mask rule. | |
| ce = F.cross_entropy(logits.transpose(1, 2), x0, reduction="none") # [B, L]. | |
| return ((1.0 / t)[:, None] * ce * (zt == MASK)).sum(-1).mean() | |
| losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet) | |
| # Run the search, keeping every completed rollout that is not dominated. | |
| torch.manual_seed(args.seed + 1) # Fix the Gumbel draws and the rollouts. | |
| root = Node(torch.full((args.length,), MASK, device=device), 0) | |
| archive, scored = ParetoArchive(), [] # The archive and every rollout's objective vector. | |
| for _ in range(args.searches): | |
| node = root # Selection walks from the root down the tree already built. | |
| while node.children: | |
| node = node.best_child() | |
| time_here = max(1.0 - node.depth / args.length, TIME_FLOOR) # The level at this depth. | |
| expand(model, node, time_here, args.branch) # Expansion adds children by one reveal each. | |
| leaf = node.children[0] if node.children else node # Score the first new child. | |
| value, finished = value_estimate(model, leaf.state, time_here, args.rollouts, | |
| args.sample_steps) | |
| backup(leaf, float(value.mean())) # One scalar reward guides the selection rule. | |
| for sequence, score in zip(finished.tolist(), objectives(finished).tolist()): | |
| archive.add(tuple(sequence), tuple(round(v, 4) for v in score)) # Keep the best front. | |
| scored.append(score) | |
| found = torch.tensor([s for _, s in archive.entries]) # [A, 2] the archive's objectives. | |
| everything = torch.tensor(scored) # [R, 2] every rollout the search scored. | |
| best = torch.tensor([list(q) for q, _ in archive.entries], device=device) # [A, L] sequences. | |
| report = { | |
| "value at the parent": at_parent, | |
| "value at the child": at_child, | |
| "reweighting factor": factor, | |
| "guided rate from 1.2": 1.2 * factor, | |
| "factor at a sibling scoring 0.2": sibling, | |
| "worked archive size": len(worked.entries), | |
| "training loss, last 50": sum(losses[-50:]) / len(losses[-50:]), | |
| "rollouts scored": len(scored), | |
| "archive size": len(archive.entries), | |
| "best GC in the archive": float(found[:, 0].max()), | |
| "best motif match in the archive": float(found[:, 1].max()), | |
| "mean GC over all rollouts": float(everything[:, 0].mean()), | |
| "mean motif match over all rollouts": float(everything[:, 1].mean()), | |
| "rollout length, steps": args.sample_steps, | |
| "GC fraction in the data": gc_fraction(train_tokens), | |
| "motif fraction in the data": motif_fraction(train_tokens), | |
| "motif fraction in the archive": motif_fraction(best), | |
| "distinct in the archive": unique_fraction(best), | |
| } | |
| print_report("search: a Pareto archive over partial sequences", report, decode_dna(best)) | |
| save_run(args.out, model=model, config=vars(args), report=report, | |
| losses=losses, samples=decode_dna(best)) | |
| # %% 07. Run the script | |
| if __name__ == "__main__": # Execute when launched directly. | |
| main() # Work the value estimate, train the model, and search under the h-transform. | |