Download lecture_4/guidance.py from ChatterjeeLab/CIS6270: direct link, hf CLI and curl.
- Browser
- Download file 19.5 kB
-
https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_4/guidance.py
- Command line
-
hf download hf://ChatterjeeLab/CIS6270/lecture_4/guidance.py
-
curl -L -o guidance.py https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_4/guidance.py
19.5 kB
| """Guiding masked diffusion on DNA with Doob's h-transform. | |
| When we condition a jump process on an event observed at the end of its path, every rate picks up | |
| the ratio of the value function at the destination to the value at the current state. The reverse | |
| process is itself such a transform, with h_t = p_t/pi, and a second transform by a classifier aims | |
| generation at a property. We estimate that classifier three ways here: the exact one, its | |
| first-order expansion in the one-hot input, and the conditional denoiser of classifier-free | |
| guidance. | |
| Chapter 4, "Conditioning a jump process: Doob's h-transform", "The reverse process as an | |
| h-transform" and "Guiding generation toward a property". 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, with a label of GC fraction at | |
| least 0.6. Prints the worked guided jump and the GC fraction under each kind of guidance. | |
| python lecture_4/guidance.py # train, then sample four ways | |
| python lecture_4/guidance.py --gamma 3 # a sharper transform | |
| python lecture_4/guidance.py --steps 20 # a few seconds, for a first look | |
| """ | |
| # %% 01. Imports and configuration | |
| import torch # Tensors, gradients and the categorical draws. | |
| import torch.nn as nn # The classifier's layers. | |
| import torch.nn.functional as F # Cross-entropy and the one-hot encoding. | |
| from cis6270.data import DNA_SIZE, decode_dna, dna_splits # ACGT, K = 4 and the shared sequences. | |
| from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction | |
| from cis6270.nets import TimeEmbedding, SequenceDenoiser, count_parameters | |
| 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. | |
| NULL = 2 # The null label the conditional denoiser drops to, one past the two real labels. | |
| DROP = 0.1 # How often training replaces the label by the null one. | |
| TIME_FLOOR = 1e-3 # The reveal rate w(t) = 1/t is unbounded at t = 0. | |
| # %% 02. Doob's h-transform | |
| # | |
| # R^h_t(x, y) = R_t(x, y) h_t(y)/h_t(x). A jump that raises the chance of the event is faster by | |
| # the factor by which it raises it, and the graph of allowed jumps is unchanged. For a certain | |
| # event h is constant, so conditioning on it leaves every rate as it was. | |
| def h_transform(R: torch.Tensor, h: torch.Tensor) -> torch.Tensor: | |
| """Reweight every off-diagonal rate by h(y)/h(x), then refill the diagonal. Returns [K, K].""" | |
| transformed = R * (h[None, :] / h[:, None]) # [K, K] one ratio per edge. | |
| transformed.fill_diagonal_(0.0) # The diagonal is fixed by the row-sum rule. | |
| return transformed - torch.diag(transformed.sum(dim=1)) # [K, K] a rate matrix again. | |
| # %% 03. The reverse process as an h-transform | |
| # | |
| # For a forward process in detailed balance with pi, the reverse rate equals the forward rate times | |
| # h_t(x)/h_t(y) with h_t = p_t/pi, so generation is the noise process aimed at the states that are | |
| # more likely under the data than under pure noise. | |
| def value_from_marginal(p: torch.Tensor, pi: torch.Tensor) -> torch.Tensor: | |
| """h_t = p_t / pi, defined wherever pi is positive, as uniform noise is and masking is not.""" | |
| return p / pi # [K] the excess of the data marginal over the noise distribution. | |
| # %% 04. A classifier on noisy sequences | |
| # | |
| # The value function for a property is h_t(x) = p(c | X_t = x), estimated by a classifier trained at | |
| # every noise level. The input is a one-hot sequence, so Section 06 can take a gradient with | |
| # respect to it. | |
| class NoisyClassifier(nn.Module): | |
| """A small network from a one-hot noisy sequence and a time to the logit of p(c | z, t).""" | |
| def __init__(self, length: int, width: int = 128): | |
| super().__init__() | |
| self.embed = nn.Linear(VOCAB, width, bias=False) # One-hot in, so the input has a gradient. | |
| self.position = nn.Embedding(length, width) # Where each token sits. | |
| self.time = TimeEmbedding(width) # How far along the corruption we are. | |
| self.body = nn.Sequential(nn.Linear(width, width), nn.SiLU(), nn.Linear(width, width), | |
| nn.SiLU()) | |
| self.head = nn.Linear(width, 1) # One logit per sequence. | |
| def forward(self, one_hot: torch.Tensor, t: torch.Tensor) -> torch.Tensor: | |
| """one_hot: [B, L, V], t: [B] -> [B] the logit of the property.""" | |
| positions = torch.arange(one_hot.shape[1], device=one_hot.device) # [L]. | |
| h = (self.embed(one_hot) + self.position(positions)[None] | |
| + self.time(t)[:, None, :]) # [B, L, width]. | |
| return self.head(self.body(h).mean(dim=1))[:, 0] # [B] pooled over the positions. | |
| def as_one_hot(z: torch.Tensor) -> torch.Tensor: | |
| """The sequence over the masked alphabet as floats, so a gradient in the input exists.""" | |
| return F.one_hot(z, VOCAB).to(torch.get_default_dtype()) # [B, L, V] one row per position. | |
| def classifier_loss(classifier: nn.Module, x0: torch.Tensor, labels: torch.Tensor) -> torch.Tensor: | |
| """Binary cross-entropy on sequences corrupted to a random noise level.""" | |
| t = torch.rand(x0.shape[0], device=x0.device).clamp(min=TIME_FLOOR) # [B] one level each. | |
| keep = torch.rand(x0.shape, device=x0.device) < (1.0 - t)[:, None] # [B, L] the survival draw. | |
| z = torch.where(keep, x0, MASK) # [B, L] a partially masked sequence. | |
| logit = classifier(as_one_hot(z), t) # [B] the predicted log odds of the property. | |
| return F.binary_cross_entropy_with_logits(logit, labels.to(torch.get_default_dtype())) | |
| # %% 05. Exact classifier guidance | |
| # | |
| # The guided rate has p(c | z^{i <- a}) in it at every position and every token, so the exact | |
| # version costs one classifier evaluation per candidate. A length-16 DNA sequence has 64 of them. | |
| def exact_ratio(classifier: nn.Module, z: torch.Tensor, t: torch.Tensor) -> torch.Tensor: | |
| """p(c | z^{i <- a}) / p(c | z) at every position and clean token. Returns [B, L, K].""" | |
| batch, length = z.shape | |
| one_hot = as_one_hot(z) # [B, L, V] the current state. | |
| here = torch.sigmoid(classifier(one_hot, t))[:, None, None] # [B, 1, 1] the value at z. | |
| candidates = one_hot[:, None, None].repeat(1, length, DNA_SIZE, 1, 1) # [B, L, K, L, V]. | |
| index = torch.arange(length, device=z.device) # [L] the position being changed. | |
| candidates[:, index, :, index] = torch.eye(VOCAB, device=z.device)[:DNA_SIZE] # Set position i. | |
| flat = candidates.reshape(batch * length * DNA_SIZE, length, VOCAB) # One batch of candidates. | |
| time = t[:, None, None].expand(batch, length, DNA_SIZE).reshape(-1) # [B L K] the same time. | |
| value = torch.sigmoid(classifier(flat, time)).view(batch, length, DNA_SIZE) # [B, L, K]. | |
| return value / here.clamp(min=1e-8) # [B, L, K] the h-transform factor on each jump. | |
| # %% 06. The gradient approximation | |
| # | |
| # One backward pass through the classifier gives every ratio at once, because a first-order | |
| # expansion of log p(c | .) in the one-hot input differs between two candidates only in the two | |
| # coordinates that change. | |
| def gradient_ratio(classifier: nn.Module, z: torch.Tensor, t: torch.Tensor) -> torch.Tensor: | |
| """exp(g_{i,a} - g_{i,z^i}) with g the gradient of log p(c | z) in the one-hot. [B, L, K].""" | |
| one_hot = as_one_hot(z).requires_grad_(True) # [B, L, V] the point of the expansion. | |
| with torch.enable_grad(): # The sampler runs under no_grad, and this one pass needs gradients. | |
| log_value = F.logsigmoid(classifier(one_hot, t)).sum() # One backward covers the batch. | |
| gradient, = torch.autograd.grad(log_value, one_hot) # [B, L, V] the expansion's slopes. | |
| current = gradient.gather(2, z[:, :, None]) # [B, L, 1] the coordinate that is being left. | |
| return (gradient[..., :DNA_SIZE] - current).exp() # [B, L, K] the approximated ratio. | |
| # %% 07. The guided sampler | |
| # | |
| # The reveal rate at a masked position is w(t) d_theta(a), so the h-transform multiplies each of | |
| # those rates by its own ratio. Both the probability of revealing anything and the letter that | |
| # appears change, and a ratio of one at every token returns the unguided sampler exactly. | |
| def denoiser_probabilities(model: nn.Module, z: torch.Tensor, time: torch.Tensor, | |
| labels: torch.Tensor | None) -> torch.Tensor: | |
| """d_theta(. | z, t) over the four bases, with the zero-mask rule applied. Returns [B, L, K].""" | |
| logits = model(z, time, labels) # [B, L, 5] the clean-token prediction. | |
| logits[..., MASK] = -1e9 # The zero-mask rule: no posterior mass belongs on m. | |
| return logits.softmax(-1)[..., :DNA_SIZE] # [B, L, K], and it sums to one over ACGT. | |
| def guided_weights(model: nn.Module, z: torch.Tensor, time: torch.Tensor, | |
| labels: torch.Tensor | None, ratio_fn, gamma: float) -> torch.Tensor: | |
| """The reveal rates over w(t): the denoiser times its h-transform ratio. Returns [B, L, K].""" | |
| weights = denoiser_probabilities(model, z, time, labels) # [B, L, K] the unguided rates. | |
| if ratio_fn is None: | |
| return weights # [B, L, K] summing to one, the unguided exit rate w(t). | |
| return weights * ratio_fn(z, time).clamp(min=1e-8) ** gamma # [B, L, K] the h-transform. | |
| # Only the gradient ratio reopens the graph, and only locally. | |
| def sample(model: nn.Module, labels: torch.Tensor | None, batch: int, length: int, | |
| steps: int = 32, ratio_fn=None, gamma: float = 1.0) -> torch.Tensor: | |
| """Generate from all masks, with the reveal rates reweighted by ratio_fn. Returns [B, L].""" | |
| device = next(model.parameters()).device | |
| z = torch.full((batch, length), MASK, device=device) # [B, L] the all-mask start. | |
| ts = torch.linspace(1.0, TIME_FLOOR, steps + 1, device=device) # The reverse grid. | |
| for t, s in zip(ts[:-1], ts[1:]): | |
| time = t.expand(batch) # [B] the shared time of this step. | |
| weights = guided_weights(model, z, time, labels, ratio_fn, gamma) # [B, L, K]. | |
| total = weights.sum(-1, keepdim=True) # [B, L, 1] the transformed exit rate over w(t). | |
| reveal = ((t - s) / t * total).clamp(max=1.0) # [B, L, 1] the probability of acting at all. | |
| drawn = torch.distributions.Categorical(probs=weights / total).sample() # [B, L]. | |
| open_now = torch.rand(z.shape, device=device)[:, :, None] < reveal # [B, L, 1]. | |
| z = torch.where((z == MASK) & open_now[..., 0], drawn, z) # Carry-over unmasking. | |
| # The last step runs down to s = 0, where the schedule reveals every remaining mask. The same | |
| # transform sets the letter, so the guidance reaches the positions that waited to the end. | |
| last = ts[-1].expand(batch) # [B] the final time of the grid. | |
| weights = guided_weights(model, z, last, labels, ratio_fn, gamma) # [B, L, K]. | |
| drawn = torch.distributions.Categorical( # [B, L] one letter per position. | |
| probs=weights / weights.sum(-1, keepdim=True)).sample() | |
| return torch.where(z == MASK, drawn, z) # [B, L] clean DNA. | |
| # %% 08. The report | |
| def main() -> None: | |
| parser = common_parser(__doc__.split("\n")[0]) | |
| parser.add_argument("--gamma", type=float, default=2.0, help="exponent on the h ratio") | |
| # Two networks train and four samplers run, so each network gets 1000 steps and each sampler | |
| # measures 64 sequences, the longest default run of the lecture. | |
| parser.set_defaults(steps=1000, sample_steps=32, samples=64) | |
| 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. | |
| # Sections 02 and 03, the reverse rates read off h_t = p_t / pi at alpha_t = 1/2. | |
| marginal = torch.tensor([0.5, 0.25, 0.125, 0.125]) # p_t over (A, C, G, T). | |
| pi = torch.full((DNA_SIZE,), 0.25) # Uniform noise. | |
| unit = torch.ones(DNA_SIZE, DNA_SIZE) - torch.diag(torch.full((DNA_SIZE,), DNA_SIZE * 1.0)) | |
| values = value_from_marginal(marginal, pi) # [4] h_t = (2, 1, 0.5, 0.5). | |
| reverse = h_transform(unit, values) # [4, 4] the reverse rates, at unit forward rates. | |
| conditioned = value_from_marginal(torch.tensor([0.625, 0.125, 0.125, 0.125]), pi) # Clean A. | |
| talk(f"h from the marginal {[round(float(v), 2) for v in values]} reverse row for C " | |
| f"{[round(float(v), 2) for v in reverse[1]]}") | |
| talk(f"h given a clean A {[round(float(v), 2) for v in conditioned]} rate C to A " | |
| f"{float(conditioned[0] / conditioned[1]):.0f} given a clean C " | |
| f"{float(conditioned[1] / conditioned[0]):.1f}") | |
| # Section 05 on paper, the worked guided jump out of C with a classifier on the four bases. | |
| unguided = torch.tensor([2.0, 0.5, 0.5]) # The reverse rates toward A, G and T. | |
| classifier_value = torch.tensor([0.1, 0.6, 0.2]) / 0.2 # p(c | destination) over p(c | C). | |
| guided = unguided * classifier_value # [3] the guided rates. | |
| sharper = unguided * classifier_value ** 2 # [3] the same transform at gamma = 2. | |
| shares = [round(float(v), 4) for v in guided / guided.sum()] # Where the jump most likely goes. | |
| talk(f"guided jump {[round(float(v), 2) for v in guided]} exit " | |
| f"{float(guided.sum()):.0f} destinations {shares}") | |
| talk(f"at gamma = 2 {[round(float(v), 2) for v in sharper]} exit " | |
| f"{float(sharper.sum()):.1f} destinations " | |
| f"{[round(float(v), 4) for v in sharper / sharper.sum()]} holding " | |
| f"{1 / float(sharper.sum()):.4f}") | |
| # Train the conditional denoiser and the classifier on the shared synthetic DNA. | |
| splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed) | |
| tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT. | |
| labels = splits["train_labels"].to(device) # [N] one when the GC fraction is at least 0.6. | |
| model = SequenceDenoiser(VOCAB, args.length, width=args.width, num_classes=2).to(device) | |
| classifier = NoisyClassifier(args.length, args.width).to(device) | |
| share = float(labels.to(torch.get_default_dtype()).mean()) # How much of the data is GC-rich. | |
| talk(f"device={device} denoiser={count_parameters(model):,} " | |
| f"classifier={count_parameters(classifier):,} labelled {share:.2f}") | |
| def denoiser_loss(step: int) -> torch.Tensor: | |
| """The MDLM loss, with the label replaced by the null one a tenth of the time.""" | |
| rows = torch.randint(0, len(tokens), (args.batch_size,), device=device) | |
| x0, y = tokens[rows], labels[rows].clone() | |
| y[torch.rand(len(y), device=device) < DROP] = NULL # Dropping the label trains both heads. | |
| 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, y) # [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() | |
| def labelled_batch(step: int) -> torch.Tensor: | |
| """One batch of clean sequences and their labels, for the classifier.""" | |
| rows = torch.randint(0, len(tokens), (args.batch_size,), device=device) | |
| return classifier_loss(classifier, tokens[rows], labels[rows]) | |
| losses = train(model, denoiser_loss, steps=args.steps, lr=args.lr, quiet=args.quiet) | |
| classifier_losses = train(classifier, labelled_batch, steps=args.steps, lr=args.lr, | |
| quiet=args.quiet) | |
| # Section 06, the exact ratios against their first-order approximation on one noisy batch. | |
| probe_t = torch.full((32,), 0.5, device=device) # [32] halfway through the corruption. | |
| probe = torch.where(torch.rand(tokens[:32].shape, device=device) < 0.5, tokens[:32], MASK) | |
| with torch.no_grad(): | |
| exact = exact_ratio(classifier, probe, probe_t) # [32, L, K] every candidate evaluated. | |
| approximate = gradient_ratio(classifier, probe, probe_t) # [32, L, K] one backward pass. | |
| gap = exact.clamp(min=1e-6).log() - approximate.clamp(min=1e-6).log() # [32, L, K] log ratios. | |
| agreement = float(gap.abs().mean()) # How far the expansion sits from the exact ratio. | |
| # Sample four ways, from the same weights and the same starting state. | |
| wanted = torch.ones(args.samples, dtype=torch.long, device=device) # Ask for the GC-rich label. | |
| null = torch.full_like(wanted, NULL) # The label the unconditional head reads. | |
| def free_ratio(z: torch.Tensor, t: torch.Tensor) -> torch.Tensor: | |
| """d_theta(a | z, c) / d_theta(a | z), the classifier ratio implied by Bayes.""" | |
| # Both denoisers go through the zero-mask rule first. The loss overwrites the mask logit, | |
| # so it never trains, and a softmax that kept it would leave a stray factor in the ratio. | |
| conditional = denoiser_probabilities(model, z, t, wanted) # [B, L, K] with the label. | |
| unconditional = denoiser_probabilities(model, z, t, null) # [B, L, K] with the null label. | |
| return conditional / unconditional.clamp(min=1e-8) # [B, L, K] the h-transform factor. | |
| runs = {} | |
| for name, ratio in ( | |
| ("unguided", None), | |
| ("exact classifier", lambda z, t: exact_ratio(classifier, z, t)), | |
| ("gradient approximation", lambda z, t: gradient_ratio(classifier, z, t)), | |
| ("classifier free", free_ratio), | |
| ): | |
| torch.manual_seed(args.seed + 1) # The same reveal draws for every run. | |
| runs[name] = sample(model, null, args.samples, args.length, args.sample_steps, | |
| ratio, args.gamma) | |
| report = { | |
| "h at A from the marginal": float(values[0]), | |
| "reverse rate C to A": float(reverse[1, 0]), | |
| "guided rate C to A": float(guided[0]), | |
| "guided rate C to G": float(guided[1]), | |
| "guided exit rate": float(guided.sum()), | |
| "guided exit rate at gamma = 2": float(sharper.sum()), | |
| "share on G at gamma = 2": float((sharper / sharper.sum())[1]), | |
| "denoiser loss, last 50": sum(losses[-50:]) / len(losses[-50:]), | |
| "classifier loss, last 50": sum(classifier_losses[-50:]) / len(classifier_losses[-50:]), | |
| "mean log-ratio gap, exact vs gradient": agreement, | |
| "GC fraction in the data": gc_fraction(tokens), | |
| "motif fraction in the data": motif_fraction(tokens), | |
| "gamma": args.gamma, | |
| } | |
| for name, drawn in runs.items(): | |
| report[f"GC fraction, {name}"] = gc_fraction(drawn) | |
| # The label is a GC threshold, so the motif is the structure guidance leaves alone. | |
| report[f"motif fraction, {name}"] = motif_fraction(drawn) | |
| report[f"distinct, {name}"] = unique_fraction(drawn) | |
| report["letter entropy, exact classifier"] = token_entropy(runs["exact classifier"], DNA_SIZE) | |
| print_report("guidance: h-transforms of masked diffusion", report, | |
| decode_dna(runs["exact classifier"])) | |
| save_run(args.out, model=model, config=vars(args), report=report, losses=losses, | |
| samples=decode_dna(runs["exact classifier"])) | |
| # %% 09. Run the script | |
| if __name__ == "__main__": # Execute when launched directly. | |
| main() # Work the guided jump, train the two networks, and sample four ways. | |