Download lecture_4/uniform.py from ChatterjeeLab/CIS6270: direct link, hf CLI and curl.
- Browser
- Download file 15.1 kB
-
https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_4/uniform.py
- Command line
-
hf download hf://ChatterjeeLab/CIS6270/lecture_4/uniform.py
-
curl -L -o uniform.py https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_4/uniform.py
15.1 kB
| """Uniform replacement on DNA, the member of the pi family with no mask. | |
| Corruption redraws a base from the uniform distribution over ACGT, so a noisy base may be the | |
| original or a replacement, and no marker of the damage appears in the sequence. Every pair of | |
| bases is then joined by a reverse rate, and the loss keeps the staying term that masking | |
| cancelled. A sampler can revise a base it has already written. | |
| Chapter 4, "A rate matrix with one free distribution", "The posterior average of conditional reverse | |
| rates" and "Uniform replacement and the rates it produces". Time runs from t = 0 clean to t = 1 | |
| noise, the survival probability is alpha_t = exp(-beta 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 | |
| posterior average of 2, the worked rate error, and the sample statistics. | |
| python lecture_4/uniform.py # train and generate | |
| python lecture_4/uniform.py --steps 20 # a few seconds, for a first look | |
| """ | |
| # %% 01. Imports and configuration | |
| import math # The worked rate errors are logarithms. | |
| import torch # Tensors, gradients and the categorical draws. | |
| 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 SequenceDenoiser, count_parameters # The denoiser d_theta(. | z, t). | |
| from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train | |
| BETA = 4.0 # The constant noise rate of the chapter's worked example, so alpha_t = exp(-4t). | |
| TIME_FLOOR = 2e-2 # The reverse rates grow like 1/(1 - alpha_t), so we stop just above zero. | |
| # %% 02. The uniform kernel and the rates that produce it | |
| # | |
| # Under R_pi = 1 pi^T - I with pi uniform, every pair of distinct bases is joined at rate beta/K. | |
| # Its matrix exponential is the kernel q_t(x | x_0) = alpha_t delta(x, x_0) + (1 - alpha_t)/K. | |
| def survival(t: torch.Tensor | float) -> torch.Tensor | float: | |
| """alpha_t = exp(-tau(t)) for the constant schedule beta(t) = BETA, so tau(t) = BETA t.""" | |
| return torch.exp(-BETA * t) if torch.is_tensor(t) else math.exp(-BETA * t) | |
| def kernel(alpha: float | torch.Tensor) -> torch.Tensor: | |
| """Q(alpha) over (A, C, G, T), with (x_0, x) entry q_t(x | x_0). Returns [K, K].""" | |
| uniform = (1.0 - alpha) / DNA_SIZE # The share of the redrawn mass that lands on each base. | |
| return alpha * torch.eye(DNA_SIZE, dtype=torch.float64) + uniform # [K, K] rows sum to one. | |
| def corrupt(x0: torch.Tensor, t: torch.Tensor) -> torch.Tensor: | |
| """Redraw each position from the uniform distribution with probability 1 - alpha_t.""" | |
| keep = torch.rand(x0.shape, device=x0.device) < survival(t) # [B, L] survival of the original. | |
| fresh = torch.randint(0, DNA_SIZE, x0.shape, device=x0.device) # [B, L] a draw from pi. | |
| return torch.where(keep, x0, fresh) # [B, L] nothing marks which positions were redrawn. | |
| # %% 03. The conditional reverse rates and their three cases | |
| # | |
| # If we divide the conditional reverse rate by the noise rate beta/K we are left with one factor. | |
| # It is 1 + K alpha_t/(1 - alpha_t) toward the clean token, its reciprocal away from it, and one | |
| # between two tokens that are both wrong. | |
| def clean_factor(alpha: float) -> float: | |
| """1 + K alpha_t / (1 - alpha_t), the factor 5 of the worked example at alpha_t = 1/2.""" | |
| return 1.0 + DNA_SIZE * alpha / (1.0 - alpha) # The ratio q_t(x_0 | x_0) / q_t(x | x_0). | |
| def conditional_rates(alpha: float, current: int, clean: int) -> torch.Tensor: | |
| """R_hat_t(c, a | x_0) for every destination a, in units where the forward rate is one.""" | |
| Q = kernel(alpha) # [K, K] the forward kernel at this survival probability. | |
| return Q[clean] / Q[clean, current] # [K] q_t(a | x_0) divided by q_t(c | x_0). | |
| # %% 04. A worked posterior average on one base | |
| # | |
| # With a clean base A at probability 0.75 and C at 0.25, an observed C at alpha_t = 1/2 has | |
| # posterior 0.375 and 0.625, and the two conditional rates 5 and 0.2 average to the marginal rate. | |
| def posterior_over_clean(alpha: float, observed: int, prior: torch.Tensor) -> torch.Tensor: | |
| """p(x_0 = b | X_t = observed), Bayes' rule with the forward kernel as the likelihood.""" | |
| weights = prior * kernel(alpha)[:, observed] # [K] prior times q_t(observed | b). | |
| return weights / weights.sum() # [K] normalized over the candidate clean bases. | |
| def marginal_reverse_rate(alpha: float, observed: int, destination: int, | |
| prior: torch.Tensor) -> float: | |
| """The posterior average of the conditional reverse rates, the marginal reverse rate.""" | |
| posterior = posterior_over_clean(alpha, observed, prior) # [K] the weights of the average. | |
| targets = kernel(alpha)[:, destination] / kernel(alpha)[:, observed] # [K] one target per clean b. | |
| return float(posterior @ targets) # The two together form the rate the sampler applies. | |
| # %% 05. The rate error and the staying term | |
| # | |
| # l(A, B) = A log(A/B) - A + B is the Bregman divergence the path KL divergence produces. The term | |
| # -A + B comes from the probability of staying put, and under uniform noise it does not cancel. | |
| def rate_error(target: torch.Tensor, model_rate: torch.Tensor) -> torch.Tensor: | |
| """Elementwise l(A, B), nonnegative and zero exactly at B = A.""" | |
| logarithmic = torch.where(target > 0, target * (target.clamp(min=1e-12) / model_rate).log(), | |
| torch.zeros_like(target)) # A log(A/B), with l(0, B) = B. | |
| return logarithmic - target + model_rate # The staying term is the -A + B part. | |
| def ratio_table(current: torch.Tensor, alpha: torch.Tensor) -> torch.Tensor: | |
| """q_t(a | b) / q_t(c | b) for every clean base b and destination a. Returns [B, L, K, K].""" | |
| uniform = (1.0 - alpha) / DNA_SIZE # [B, 1] the redrawn share at this noise level. | |
| Q = (alpha[..., None, None] * torch.eye(DNA_SIZE, device=current.device) | |
| + uniform[..., None, None]) # [B, 1, K, K] the kernel q_t(a | b) at each sequence's time. | |
| denominator = Q.expand(-1, current.shape[1], -1, -1).gather( | |
| 3, current[:, :, None, None].expand(-1, -1, DNA_SIZE, 1)) # [B, L, K, 1] q_t(c | b). | |
| return Q / denominator # [B, L, K, K] the only quantity the rates need from the kernel. | |
| def model_rates(denoiser: torch.Tensor, ratios: torch.Tensor) -> torch.Tensor: | |
| """R_hat^theta_t(c, a) = (beta/K) sum_b d_theta(b) q_t(a | b) / q_t(c | b). Returns [B, L, K].""" | |
| averaged = torch.einsum("nlb,nlba->nla", denoiser, ratios) # [B, L, K] averaged over clean bases. | |
| return (BETA / DNA_SIZE) * averaged # [B, L, K] the forward rate times the posterior average. | |
| def conditional_targets(x0: torch.Tensor, ratios: torch.Tensor) -> torch.Tensor: | |
| """R_hat_t(c, a | x_0), the same formula with the posterior collapsed onto the clean token.""" | |
| rows = x0[:, :, None, None].expand(-1, -1, 1, DNA_SIZE) # [B, L, 1, K] select the row b = x_0. | |
| return (BETA / DNA_SIZE) * ratios.gather(2, rows).squeeze(2) # [B, L, K] one target per jump. | |
| def rate_loss(model: torch.nn.Module, x0: torch.Tensor) -> torch.Tensor: | |
| """One Monte Carlo sample of L_cond, summed over destinations and positions.""" | |
| t = torch.rand(x0.shape[0], 1, device=x0.device).clamp(min=TIME_FLOOR) # [B, 1] one time each. | |
| alpha = survival(t) # [B, 1] the survival probability at that time. | |
| zt = corrupt(x0, t) # [B, L] the noisy sequence, with no marker of which positions moved. | |
| ratios = ratio_table(zt, alpha) # [B, L, K, K] the kernel ratios at this noise level. | |
| denoiser = model(zt, t[:, 0]).softmax(-1) # [B, L, K] the posterior over clean tokens. | |
| predicted = model_rates(denoiser, ratios).clamp(min=1e-8) # [B, L, K] nonnegative by construction. | |
| target = conditional_targets(x0, ratios) # [B, L, K] the rate attached to this clean sequence. | |
| staying = torch.nn.functional.one_hot(zt, DNA_SIZE).bool() # [B, L, K] the destination c itself. | |
| return rate_error(target, predicted).masked_fill(staying, 0.0).sum((1, 2)).mean() | |
| # %% 06. Sampling by tau-leaping | |
| # | |
| # Over a step of length h a position moves to a with probability h times its reverse rate, and the | |
| # remaining probability keeps it where it is. The step is valid while h times the total rate is one. | |
| # Generation needs no gradient tracking. | |
| def sample(model: torch.nn.Module, batch: int, length: int, steps: int = 64) -> torch.Tensor: | |
| """Generate from uniformly random tokens down to clean DNA. Returns [B, L] over ACGT.""" | |
| device = next(model.parameters()).device | |
| z = torch.randint(0, DNA_SIZE, (batch, length), device=device) # [B, L] a draw from pi. | |
| ts = torch.linspace(1.0, TIME_FLOOR, steps + 1, device=device) # The reverse grid. | |
| for t, s in zip(ts[:-1], ts[1:]): | |
| time_row = t.expand(batch, 1) # [B, 1] the shared time of this step. | |
| denoiser = model(z, time_row[:, 0]).softmax(-1) # [B, L, K] the clean-token prediction. | |
| rates = model_rates(denoiser, ratio_table(z, survival(time_row))) # [B, L, K] per destination. | |
| jump = (t - s) * rates # [B, L, K] the tau-leaping probabilities of this step. | |
| jump.scatter_(2, z[:, :, None], 0.0) # Only a change of base counts as a jump. | |
| total = jump.sum(-1, keepdim=True).clamp(min=1.0) # Rescale when the step would exceed one. | |
| jump = jump / total # [B, L, K] now a sub-probability vector at every position. | |
| stay = (1.0 - jump.sum(-1, keepdim=True)).clamp(min=0.0) # [B, L, 1] the chance of staying. | |
| choice = torch.distributions.Categorical( | |
| probs=torch.cat([jump, stay], dim=-1)).sample() # [B, L] destination, or K for staying. | |
| z = torch.where(choice == DNA_SIZE, z, choice.clamp(max=DNA_SIZE - 1)) # [B, L]. | |
| final = model(z, ts[-1].expand(batch)).softmax(-1) # The exact posterior step down to t = 0. | |
| return torch.distributions.Categorical(probs=final).sample() # [B, L] clean DNA. | |
| # %% 07. The report | |
| def main() -> None: | |
| parser = common_parser(__doc__.split("\n")[0]) | |
| # The rate loss needs a longer budget than the worked numbers do, so the default trains for | |
| # 1000 steps and measures on 64 sequences, and that keeps the run inside about a minute. | |
| parser.set_defaults(steps=1000, sample_steps=64, 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 03 and 04, the three cases and the worked posterior average on one base. | |
| prior = torch.tensor([0.75, 0.25, 0.0, 0.0], dtype=torch.float64) # Clean A at 0.75, C at 0.25. | |
| posterior = posterior_over_clean(0.5, 1, prior) # [4] after observing a C at alpha_t = 1/2. | |
| targets = conditional_rates(0.5, current=1, clean=0) # [4] the rates attached to a clean A. | |
| average = marginal_reverse_rate(0.5, 1, 0, prior) # The posterior average, and it comes out 2. | |
| marginal = prior @ kernel(0.5) # [4] p_t over the four bases, or (0.5, 0.25, 0.125, 0.125). | |
| talk(f"kernel at alpha=0.5 clean {float(kernel(0.5)[0, 0]):.3f} other " | |
| f"{float(kernel(0.5)[0, 1]):.3f} clean factor {clean_factor(0.5):.0f}") | |
| talk(f"posterior after C {[round(float(v), 3) for v in posterior]} conditional rates " | |
| f"{float(targets[0]):.0f} under a clean A and " | |
| f"{float(conditional_rates(0.5, 1, 1)[0]):.1f} under a clean C") | |
| talk(f"posterior average {average:.1f} marginals {[round(float(v), 3) for v in marginal]} " | |
| f"ratio {float(marginal[0] / marginal[1]):.1f} to G and T " | |
| f"{marginal_reverse_rate(0.5, 1, 2, prior):.1f}") | |
| # Section 05, the rate error on one base, where the staying term keeps the sum from collapsing. | |
| worked_denoiser = torch.tensor([[[0.5, 0.3, 0.1, 0.1]]], dtype=torch.float64) # [1, 1, 4]. | |
| worked_current = torch.tensor([[1]]) # The current base is C. | |
| worked_alpha = torch.tensor([[0.5]], dtype=torch.float64) # alpha_t = 1/2. | |
| worked_ratios = ratio_table(worked_current, worked_alpha) # [1, 1, K, K] at alpha_t = 1/2. | |
| predicted = model_rates(worked_denoiser, worked_ratios)[0, 0] / (BETA / DNA_SIZE) | |
| target = conditional_rates(0.5, current=1, clean=0) # The clean base is A. | |
| error = float(rate_error(target[[0, 2, 3]], predicted[[0, 2, 3]]).sum()) # Skip the current base. | |
| talk(f"model rates from d {[round(float(v), 2) for v in predicted]} targets " | |
| f"{[round(float(v), 1) for v in target]} rate error {error:.3f}") | |
| # Train the denoiser against the conditional rate targets. | |
| 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. | |
| valid_tokens = splits["valid_tokens"].to(device) # [M, L] held out for the loss below. | |
| model = SequenceDenoiser(DNA_SIZE, args.length, width=args.width).to(device) | |
| talk(f"device={device} parameters={count_parameters(model):,} train={len(train_tokens)}") | |
| def step_loss(step: int) -> torch.Tensor: | |
| rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device) | |
| return rate_loss(model, train_tokens[rows]) # One clean batch into one scalar. | |
| losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet) | |
| with torch.no_grad(): | |
| held_out = sum(float(rate_loss(model, valid_tokens)) for _ in range(4)) / 4.0 | |
| torch.manual_seed(args.seed + 1) # Fix the jump draws, so the samples repeat. | |
| samples = sample(model, args.samples, args.length, args.sample_steps) # [B, L] over ACGT. | |
| report = { | |
| "q_t(x_0 | x_0) at alpha=0.5": float(kernel(0.5)[0, 0]), | |
| "clean factor at alpha=0.5": clean_factor(0.5), | |
| "posterior on A after a C": float(posterior[0]), | |
| "conditional rate under A": float(targets[0]), | |
| "conditional rate under C": float(conditional_rates(0.5, 1, 1)[0]), | |
| "posterior average rate": average, | |
| "rate from the marginals": float(marginal[0] / marginal[1]), | |
| "worked model rate to A": float(predicted[0]), | |
| "worked rate error": error, | |
| "training rate loss, last 50": sum(losses[-50:]) / len(losses[-50:]), | |
| "validation rate loss": held_out, | |
| "GC fraction": gc_fraction(samples), | |
| "GC fraction in the data": gc_fraction(train_tokens), | |
| "motif fraction": motif_fraction(samples), | |
| "motif fraction in the data": motif_fraction(train_tokens), | |
| "distinct samples": unique_fraction(samples), | |
| "letter entropy, nats": token_entropy(samples, DNA_SIZE), | |
| } | |
| print_report("uniform: replacement diffusion on DNA", report, decode_dna(samples)) | |
| save_run(args.out, model=model, config=vars(args), report=report, | |
| losses=losses, samples=decode_dna(samples)) | |
| # %% 08. Run the script | |
| if __name__ == "__main__": # Execute when launched directly. | |
| main() # Work the posterior average, train against the rate loss, and generate. | |