Download lecture_5/guidance.py from ChatterjeeLab/CIS6270: direct link, hf CLI and curl.
- Browser
- Download file 12.9 kB
-
https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_5/guidance.py
- Command line
-
hf download hf://ChatterjeeLab/CIS6270/lecture_5/guidance.py
-
curl -L -o guidance.py https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_5/guidance.py
12.9 kB
| """Guidance: steering a trained flow toward a property by tilting its rates. | |
| The distribution we want is the data distribution reweighted by the likelihood of a property, with a | |
| normalizer over every sequence. A rate is a ratio, so that constant cancels, and the conditioned | |
| process multiplies each rate by the ratio of a value function at the destination to its value at the | |
| current state. If we expand the logarithm of that ratio to first order, one backward pass replaces | |
| one property evaluation per destination. An exponent on the ratio gives one strength parameter, and | |
| the classifier-free form reaches that parameter from two trained velocities and no classifier. | |
| Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity | |
| u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y). | |
| Chapter 5, Sections 5.24 to 5.29, with the listing of Section 5.33. Several objectives at once are in | |
| ``lecture_5/multi_objective.py``. Trained on the synthetic DNA of ``cis6270.data``, with the GC label | |
| as the property. Prints the worked guided base, the guidance strength, the Taylor tilt, and the GC | |
| fraction of a batch generated with and without guidance. | |
| python lecture_5/guidance.py # train, then sample at gamma = 0 and gamma = 2 | |
| python lecture_5/guidance.py --gamma 5 # a harder tilt toward the GC-rich label | |
| python lecture_5/guidance.py --steps 20 --quiet # a few seconds, for a first look | |
| """ | |
| # %% 01. Imports and configuration | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| from cis6270.data import DNA_SIZE, decode_dna, dna_splits | |
| from cis6270.metrics import gc_fraction, motif_fraction, token_entropy | |
| from cis6270.nets import SequenceDenoiser, TimeEmbedding, count_parameters | |
| from cis6270.runner import common_parser, print_report, save_run, set_seed, train | |
| V = DNA_SIZE + 1 # Four bases plus the mask symbol, and the sampler starts from it. | |
| MASK = DNA_SIZE # Index 4 is the mask symbol m. | |
| def k(t): | |
| """The schedule kappa_t, linear here.""" | |
| return t | |
| def kd(t): | |
| """Its derivative kappa_dot_t, held at one by the linear schedule.""" | |
| return torch.ones_like(t) | |
| # %% 02. Guidance as a tilt of the rates | |
| # | |
| # The conditioned velocity is the unguided one multiplied by the ratio of the property probability at | |
| # the destination to its value at the current state. A move toward a destination the property model | |
| # scores higher happens sooner, and a destination with rate zero stays at rate zero, so the tilt | |
| # reweights the support of the model without enlarging it. | |
| def tilt(rates, destination_scores, current_score, gamma=1.0): | |
| """u^(gamma)_t(y | x, c) = [p(c | y, t)/p(c | x, t)]^gamma u_t(y, x).""" | |
| return rates * (destination_scores / current_score) ** gamma # gamma = 1 is the exact process. | |
| def h_value(denoiser, destination_scores): | |
| """h_t(x) = E[p(c | X_1) | X_t = x], the posterior average of the property likelihood.""" | |
| return float((denoiser * destination_scores).sum()) # The exact value at a masked position. | |
| def classifier_free(conditional, unconditional, gamma): | |
| """u_c^gamma u_u^(1-gamma), the geometric mixture that reaches the same one-parameter family.""" | |
| return conditional ** gamma * unconditional ** (1.0 - gamma) # Linear in the logarithms. | |
| def reachable(positions, letters): | |
| """D(S - 1) + 1, the number of states one jump can reach, since exactly one position changes.""" | |
| return positions * (letters - 1) + 1 # 2001 for a 100-residue peptide over 21 symbols. | |
| # %% 03. A first-order expansion of the log ratio | |
| # | |
| # If we embed a sequence as one-hot vectors, a single-position change is a sparse difference. We | |
| # then expand the log property probability around the current sequence. The inner product then | |
| # collapses onto the difference of two gradient coordinates. One forward and one backward pass | |
| # serve every destination. | |
| def taylor_log_ratio(gradient, current): | |
| """[grad log p_phi]_{i,b} - [grad log p_phi]_{i,a} at one position. gradient: [K] -> [K].""" | |
| return gradient - gradient[current] # Zero at the letter in place, with ratio one. | |
| # %% 04. The guided sampler | |
| # | |
| # One guided step differs from the unguided one at a single line, the factor exp(gamma adv). The | |
| # one-hot encoding has gradients switched on, so we can differentiate the property network against | |
| # letters that are not currently in place. We sum over the batch before differentiating, and one | |
| # backward pass serves all of it. We read the denoiser over the four bases alone, since the data | |
| # token is never the mask. That holds the rate back into the mask at zero, and the exit rate at | |
| # kappa_dot/(1 - kappa) when gamma is zero. | |
| def guided_step(model, prop, xt, t, h, gamma=1.0): | |
| """One Euler step of the tilted velocity at every position at once. xt: [B, D] -> [B, D].""" | |
| oh = F.one_hot(xt, V).to(torch.get_default_dtype()).requires_grad_() | |
| logp = prop(oh, t).sum() # property on noisy x | |
| g = torch.autograd.grad(logp, oh)[0] # one backward pass | |
| adv = g - (oh * g).sum(-1, keepdim=True) # Taylor ratio | |
| with torch.no_grad(): | |
| bases = model(xt, t)[..., :DNA_SIZE].softmax(-1) # denoiser | |
| p1 = F.pad(bases, (0, V - DNA_SIZE)) # the data token is never the mask | |
| rate = kd(t) / (1 - k(t)).clamp(min=1e-4) | |
| u = rate[:, None, None] * p1 * torch.exp(gamma * adv) # every rate tilted | |
| u.scatter_(-1, xt[..., None], 0.0) # no self-transition | |
| jump = torch.rand(xt.shape) < h * u.sum(-1) | |
| y = torch.distributions.Categorical(probs=u).sample() | |
| return torch.where(jump, y, xt) | |
| class PropertyModel(nn.Module): | |
| """log p_phi(c | x, t) from a one-hot sequence and a time, trained on noisy intermediate states.""" | |
| def __init__(self, length, width=64): | |
| super().__init__() | |
| self.time_embedding = TimeEmbedding(width, scale=1.0) # The property depends on the time. | |
| self.body = nn.Sequential( | |
| nn.Linear(length * V + width, width), nn.SiLU(), nn.Linear(width, 1), | |
| ) | |
| def forward(self, oh, t): | |
| """oh: [B, D, V] one-hot and differentiable, t: [B] -> [B] log probability of the property.""" | |
| h = torch.cat([oh.flatten(1), self.time_embedding(t)], dim=1) # [B, D V + width]. | |
| return F.logsigmoid(self.body(h)).squeeze(-1) # [B] log p_phi(c | x, t). | |
| # %% 05. Training the denoiser and the property model | |
| # | |
| # The denoiser is the one built earlier in this chapter, trained from the mask source. The property | |
| # model reads the same noisy sequences and predicts the GC label of the clean one, so it is a | |
| # function of an intermediate state and not of a clean sequence. | |
| def corrupt(x1, t): | |
| """A draw from the conditional path with the all-mask source. x1: [B, D], t: [B] -> [B, D].""" | |
| heads = torch.rand(x1.shape) < k(t)[:, None] # Heads with probability kappa_t at each position. | |
| return torch.where(heads, x1, torch.full_like(x1, MASK)) # Data token on heads, mask on tails. | |
| def generate(model, prop, batch, length, steps, gamma): | |
| """Run the guided sampler from all masks to t = 1. Returns [B, D].""" | |
| x = torch.full((batch, length), MASK, dtype=torch.long) # The all-mask source. | |
| grid = torch.linspace(0.0, 1.0, steps + 1) | |
| for t, s in zip(grid[:-1], grid[1:]): | |
| with torch.enable_grad(): # The property gradient needs a graph inside the no-grad sampler. | |
| x = guided_step(model, prop, x, t.expand(batch), float(s - t), gamma) | |
| p1 = model(x, torch.ones(batch))[..., :DNA_SIZE].softmax(-1) # The data token is never the mask. | |
| drawn = torch.distributions.Categorical(probs=p1).sample() # lambda_t reaching one on the last step. | |
| return torch.where(x == MASK, drawn, x) # We draw any position still masked from the denoiser. | |
| # %% 06. The report | |
| def main(): | |
| parser = common_parser(__doc__.split("\n")[0]) | |
| parser.add_argument("--gamma", type=float, default=2.0, help="guidance strength") | |
| parser.set_defaults(steps=1000, width=64, samples=64) | |
| args = parser.parse_args() | |
| set_seed(args.seed) | |
| # Section 5.27: a masked base at t = 0.6 with the chapter's denoiser and property scores. | |
| p1 = torch.tensor([0.1, 0.2, 0.3, 0.4], dtype=torch.double) # The denoiser over A, C, G, T. | |
| rates = p1 / (1.0 - 0.6) # The unguided unmasking rates, 2.5 times the denoiser. | |
| scores = torch.tensor([0.10, 0.35, 0.55, 0.25], dtype=torch.double) # p(c | a, t). | |
| guided = tilt(rates, scores, 0.20) # The property model scores the masked state at 0.20. | |
| report = { | |
| "unguided_rates": [round(float(r), 4) for r in rates], # (0.25, 0.50, 0.75, 1.00). | |
| "unguided_exit_rate": round(float(rates.sum()), 4), # 2.50. | |
| "likelihood_ratios": [round(float(r), 4) for r in scores / 0.20], # (0.50, 1.75, 2.75, 1.25). | |
| "guided_rates": [round(float(r), 4) for r in guided], # (0.125, 0.875, 2.063, 1.250). | |
| "guided_exit_rate": round(float(guided.sum()), 4), # 4.3125. | |
| "guided_destination": [round(float(r), 3) for r in guided / guided.sum()], # (0.029, ...). | |
| "bayes_products": [round(float(r), 3) for r in p1 * scores], # (0.010, 0.070, 0.165, 0.100). | |
| "exact_h": round(h_value(p1, scores), 4), # 0.345. | |
| "exit_rate_at_exact_h": round(float(tilt(rates, scores, h_value(p1, scores)).sum()), 4), # 2.5. | |
| "reachable_states": reachable(100, 21), # 2001 destinations for a 100-residue peptide. | |
| } | |
| # Section 5.28: the same base at guidance strength two, and the classifier-free form. | |
| harder = tilt(rates, scores, 0.20, gamma=2.0) | |
| report["gamma_2_rates"] = [round(float(r), 4) for r in harder] # (0.0625, 1.531, 5.672, 1.563). | |
| report["gamma_2_exit_rate"] = round(float(harder.sum()), 4) # 8.828. | |
| report["gamma_2_destination"] = [round(float(r), 3) for r in harder / harder.sum()] | |
| mixed = classifier_free(tilt(rates, scores, 0.20), rates, gamma=2.0) | |
| report["classifier_free_agrees"] = bool(torch.allclose(mixed, harder)) # The same family. | |
| # Section 5.29: the Taylor tilt against the exact ratio. | |
| gradient = torch.tensor([-2.10, -1.15, -0.75, -1.30, -1.61], dtype=torch.double) # With the mask. | |
| approximate = taylor_log_ratio(gradient, MASK)[:DNA_SIZE] # The mask is the letter in place. | |
| report["taylor_log_ratios"] = [round(float(r), 3) for r in approximate] # (-0.49, 0.46, ...). | |
| report["exact_log_ratios"] = [round(float(r), 3) for r in (scores / 0.20).log()] # (-0.693, ...). | |
| taylor_rates = rates * approximate.exp() | |
| report["taylor_destination"] = [round(float(r), 3) for r in taylor_rates / taylor_rates.sum()] | |
| # Training the denoiser and the property model on the shared DNA, then sampling twice. | |
| splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed) | |
| data, labels = splits["train_tokens"], splits["train_labels"] # [N, L] and [N] the GC label. | |
| model = SequenceDenoiser(V, args.length, width=args.width, depth=2) | |
| prop = PropertyModel(args.length, width=args.width) | |
| def loss_fn(step): | |
| rows = torch.randint(0, len(data), (args.batch_size,)) # One minibatch of data sequences. | |
| x1 = data[rows] # [B, L] the sequences the denoiser has to recover. | |
| y = labels[rows].to(torch.get_default_dtype()) # [B] the property to steer toward. | |
| t = torch.rand(args.batch_size) # One time per sequence. | |
| xt = corrupt(x1, t) # [B, L] a draw from the conditional path. | |
| denoise = F.cross_entropy(model(xt, t)[..., :DNA_SIZE].transpose(1, 2), x1) | |
| logp = prop(F.one_hot(xt, V).to(torch.get_default_dtype()), t) # [B] the property on x_t. | |
| property_loss = -(y * logp + (1 - y) * torch.log1p(-logp.exp().clamp(max=1 - 1e-6))).mean() | |
| return denoise + property_loss # The two networks share one optimizer here. | |
| if not args.quiet: | |
| print(f"denoiser={count_parameters(model):,} property={count_parameters(prop):,}") | |
| losses = train(model, loss_fn, steps=args.steps, lr=args.lr, quiet=args.quiet, | |
| extra_parameters=list(prop.parameters())) | |
| plain = generate(model, prop, args.samples, args.length, args.sample_steps, gamma=0.0) | |
| steered = generate(model, prop, args.samples, args.length, args.sample_steps, gamma=args.gamma) | |
| report["final_loss"] = sum(losses[-20:]) / len(losses[-20:]) | |
| report["gc_fraction_unguided"] = gc_fraction(plain) # The training data sit at 0.577. | |
| report[f"gc_fraction_gamma_{args.gamma:g}"] = gc_fraction(steered) # Guidance raises it. | |
| report["motif_fraction_unguided"] = motif_fraction(plain) | |
| report["token_entropy_unguided"] = token_entropy(plain, DNA_SIZE) | |
| print_report("guidance", report, decode_dna(steered)) | |
| save_run(args.out, model, vars(args), report, losses, decode_dna(steered)) | |
| if __name__ == "__main__": | |
| main() | |