Download lecture_4/block.py from ChatterjeeLab/CIS6270: direct link, hf CLI and curl.
- Browser
- Download file 11.1 kB
-
https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_4/block.py
- Command line
-
hf download hf://ChatterjeeLab/CIS6270/lecture_4/block.py
-
curl -L -o block.py https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_4/block.py
11.1 kB
| """Block diffusion on DNA, with the block size as the dial between diffusion and autoregression. | |
| The chain rule over blocks is exact, and each factor is a distribution over one block of tokens | |
| conditioned on a clean prefix. Masked diffusion models that factor directly. At one block the | |
| bound is the MDLM loss, at blocks of a single position it is the autoregressive negative | |
| log-likelihood with no slack, and every size in between interpolates. | |
| Chapter 4, "Blocks between diffusion and autoregression" and "The block size as a dial between two | |
| models". 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 bound of 2.7567 nats, the one-position identity, | |
| and the sample statistics. | |
| python lecture_4/block.py # train and generate with blocks of four | |
| python lecture_4/block.py --block 1 # the autoregressive end of the dial | |
| python lecture_4/block.py --steps 20 # a few seconds, for a first look | |
| """ | |
| # %% 01. Imports and configuration | |
| import math # The worked bound is a sum of logarithms. | |
| 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, 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 | |
| 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. | |
| # %% 02. One block, with a clean prefix and a masked suffix | |
| # | |
| # The rate matrix acts only on the current block. Everything before it is clean and conditions the | |
| # prediction, and everything after it is hidden from the denoiser. | |
| def block_input(x0: torch.Tensor, block: torch.Tensor, size: int, t: torch.Tensor) -> torch.Tensor: | |
| """The network input for one block: clean prefix, corrupted block, masked suffix. [B, L].""" | |
| position = torch.arange(x0.shape[1], device=x0.device)[None, :] # [1, L] the column index. | |
| start = (block * size)[:, None] # [B, 1] where the current block begins. | |
| inside = (position >= start) & (position < start + size) # [B, L] the current block. | |
| keep = torch.rand(x0.shape, device=x0.device) < (1.0 - t) # [B, L] the survival draw. | |
| corrupted = torch.where(keep, x0, MASK) # [B, L] masking applied inside the block. | |
| return torch.where(position < start, x0, torch.where(inside, corrupted, MASK)) # [B, L]. | |
| def block_loss(model: torch.nn.Module, x0: torch.Tensor, size: int) -> torch.Tensor: | |
| """One Monte Carlo sample of the block bound, with one block and one time per sequence.""" | |
| blocks = math.ceil(x0.shape[1] / size) # B, the number of blocks the sequence splits into. | |
| block = torch.randint(0, blocks, (x0.shape[0],), device=x0.device) # [B] which factor to sample. | |
| t = torch.rand(x0.shape[0], device=x0.device).clamp(min=TIME_FLOOR) # [B] one noise level each. | |
| z = block_input(x0, block, size, t[:, None]) # [B, L] the denoiser's input. | |
| position = torch.arange(x0.shape[1], device=x0.device)[None, :] # [1, L]. | |
| start = (block * size)[:, None] # [B, 1]. | |
| inside = (position >= start) & (position < start + size) # [B, L] the current block. | |
| logits = model(z, t) # [B, L, 5] the clean-token prediction at every position. | |
| logits[..., MASK] = -1e9 # The zero-mask rule: no posterior mass belongs on m. | |
| ce = F.cross_entropy(logits.transpose(1, 2), x0, reduction="none") # [B, L]. | |
| scored = ce * ((z == MASK) & inside) # [B, L] only the masked positions of the current block. | |
| # We draw one block uniformly to estimate a sum over blocks, so the term carries that count. | |
| return (blocks * (1.0 / t)[:, None] * scored).sum(-1).mean() # Nats per sequence. | |
| # %% 03. The block size as a dial | |
| # | |
| # At a block of one position the time integral collapses. The position is masked with probability | |
| # 1 - alpha_t and weighted by w(t) = -alpha_dot/(1 - alpha_t), so the integrand is -alpha_dot times | |
| # the cross-entropy, and the remaining integral is alpha_0 - alpha_1 = 1. | |
| def one_position_integral(grid: int = 2048) -> float: | |
| """The integral of w(t)(1 - alpha_t) over [0, 1], made exactly one by the collapse.""" | |
| t = (torch.arange(grid).double() + 0.5) / grid # [grid] midpoints of a uniform partition. | |
| return float(((1.0 / t) * t).mean()) # w(t)(1 - alpha_t) = 1 at every t on the linear schedule. | |
| def one_position_estimate(probability: float, draws: int, generator) -> float: | |
| """The same integral by Monte Carlo, the way the training loss evaluates it.""" | |
| t = torch.rand(draws, generator=generator).clamp(min=TIME_FLOOR) # [N] uniform times. | |
| masked = torch.rand(draws, generator=generator) < t # [N] the single position is masked or not. | |
| return float(((1.0 / t) * masked * -math.log(probability)).mean()) # Should land on -log d. | |
| # %% 04. Generating block by block | |
| # Generation needs no gradient tracking. | |
| def sample(model: torch.nn.Module, batch: int, length: int, size: int, | |
| steps: int = 32) -> torch.Tensor: | |
| """Fill one block at a time by masked diffusion, with the finished blocks as a clean prefix.""" | |
| device = next(model.parameters()).device | |
| z = torch.full((batch, length), MASK, device=device) # [B, L] every position starts masked. | |
| ts = torch.linspace(1.0, TIME_FLOOR, steps + 1, device=device) # The reverse grid per block. | |
| position = torch.arange(length, device=device)[None, :] # [1, L] the column index. | |
| for start in range(0, length, size): # Blocks are completed in order, left to right. | |
| inside = (position >= start) & (position < start + size) # [1, L] the current block. | |
| for t, s in zip(ts[:-1], ts[1:]): | |
| logits = model(z, t.expand(batch)) # [B, L, 5] the prefix conditions every prediction. | |
| logits[..., MASK] = -1e9 # The zero-mask rule, at sampling time. | |
| x0 = torch.distributions.Categorical(logits=logits).sample() # [B, L] candidate tokens. | |
| unmask = torch.rand(z.shape, device=device) < (t - s) / t # [B, L] the reveal draw. | |
| z = torch.where((z == MASK) & unmask & inside, x0, z) # Reveal inside this block only. | |
| logits = model(z, ts[-1].expand(batch)) # The last step of the block runs down to s = 0. | |
| logits[..., MASK] = -1e9 | |
| final = torch.distributions.Categorical(logits=logits).sample() # [B, L]. | |
| z = torch.where((z == MASK) & inside, final, z) # The block is clean before the next starts. | |
| return z # [B, L] clean DNA. | |
| # %% 05. The report | |
| def main() -> None: | |
| parser = common_parser(__doc__.split("\n")[0]) | |
| parser.add_argument("--block", type=int, default=4, help="L', the block length") | |
| # Generation runs the whole reverse grid once per block, so --sample-steps stays at 16 while | |
| # training rises to 1000 steps and the report measures 64 sequences, inside about a minute. | |
| parser.set_defaults(steps=1000, sample_steps=16, 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. | |
| # Section 02, one draw of the bound on ACGTAC with blocks of two at t = 0.5. | |
| weight = 1.0 / 0.5 # w(0.5) = 2 on the linear schedule. | |
| contributions = [ # Block 1 masked twice, block 2 once, block 3 once. | |
| weight * (-math.log(0.5) - math.log(0.8)), | |
| weight * -math.log(0.7), | |
| weight * -math.log(0.9), | |
| ] | |
| talk(f"worked block bound {[round(v, 4) for v in contributions]} total " | |
| f"{sum(contributions):.4f} nats on ACGTAC") | |
| # Section 03, the one-position identity that makes blocks of length one autoregressive. | |
| generator = torch.Generator().manual_seed(args.seed) # A fixed stream for the estimate. | |
| exact = one_position_integral() # The integral of w(t)(1 - alpha_t), equal to one. | |
| estimate = one_position_estimate(0.7, 20000, generator) # The same number by Monte Carlo. | |
| talk(f"one-position dial integral {exact:.4f} Monte Carlo bound {estimate:.4f} against " | |
| f"-log 0.7 = {-math.log(0.7):.4f}") | |
| # Train the denoiser on the shared synthetic DNA at the chosen block size. | |
| 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 bound below. | |
| model = SequenceDenoiser(VOCAB, args.length, width=args.width).to(device) | |
| blocks = math.ceil(args.length / args.block) # How many factors the chain rule has. | |
| talk(f"device={device} parameters={count_parameters(model):,} " | |
| f"blocks={blocks} of length {args.block}") | |
| def step_loss(step: int) -> torch.Tensor: | |
| rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device) | |
| return block_loss(model, train_tokens[rows], args.block) # One clean batch into one scalar. | |
| losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet) | |
| with torch.no_grad(): | |
| bound = sum(float(block_loss(model, valid_tokens, args.block)) for _ in range(8)) / 8.0 | |
| torch.manual_seed(args.seed + 1) # Fix the reveal draws, so the samples repeat. | |
| samples = sample(model, args.samples, args.length, args.block, args.sample_steps) | |
| report = { | |
| "worked bound on ACGTAC": sum(contributions), | |
| "first block of the worked bound": contributions[0], | |
| "integral of w(t)(1 - alpha_t)": exact, | |
| "one-position Monte Carlo bound": estimate, | |
| "blocks": blocks, | |
| "block length": args.block, | |
| "training bound, last 50": sum(losses[-50:]) / len(losses[-50:]), | |
| "validation bound, nats/seq": bound, | |
| "bound, nats per token": bound / args.length, | |
| "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(f"block: diffusion in blocks of {args.block}", report, decode_dna(samples)) | |
| save_run(args.out, model=model, config=vars(args), report=report, | |
| losses=losses, samples=decode_dna(samples)) | |
| # %% 06. Run the script | |
| if __name__ == "__main__": # Execute when launched directly. | |
| main() # Work the block bound, train at one block size, and generate block by block. | |