CIS6270 / lecture_4 /block.py
pranamanam's picture
Upload 87 files
0ba9d09 verified
Raw History Blame Contribute Delete
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
@torch.no_grad() # 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.