Download lecture_5/dfm.py from ChatterjeeLab/CIS6270: direct link, hf CLI and curl.
- Browser
- Download file 13.2 kB
-
https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_5/dfm.py
- Command line
-
hf download hf://ChatterjeeLab/CIS6270/lecture_5/dfm.py
-
curl -L -o dfm.py https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_5/dfm.py
13.2 kB
| """Discrete flow matching, the whole construction in one file. | |
| We pick the probability path first, write down a velocity that generates it, and read the sampler off | |
| the velocity. Each position follows the mixture path between a source token and a data token, and | |
| its conditional velocity is a schedule factor times the difference of two point masses. The | |
| posterior average of those conditional velocities is the same factor times a denoiser. | |
| Cross-entropy on the data token recovers that denoiser, and one Euler step redraws a position from | |
| it with probability lambda_t. Chapter 4 fixed the corruption and solved for its reverse; here the | |
| corruption is a consequence of the path we chose. | |
| 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.5 to 5.11. Trained on the synthetic DNA of ``cis6270.data``. Prints the | |
| chapter's worked numbers, the training loss and the measurements of a generated batch. | |
| python lecture_5/dfm.py # train and generate from the uniform source | |
| python lecture_5/dfm.py --source mask # the all-mask source instead | |
| python lecture_5/dfm.py --steps 20 --quiet # a few seconds, for a first look | |
| """ | |
| # %% 01. Imports and configuration | |
| import torch | |
| import torch.nn.functional as F | |
| from torch.distributions import Categorical | |
| from cis6270.data import DNA_SIZE, decode_dna, dna_splits, encode_dna | |
| from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction | |
| from cis6270.nets import SequenceDenoiser, count_parameters | |
| from cis6270.runner import common_parser, print_report, save_run, set_seed, train | |
| # %% 02. The class of Section 5.11, with both sources | |
| # | |
| # The constructor holds the vocabulary size V and the schedule kappa_t with its derivative | |
| # kappa_dot_t, passed as functions of a time tensor. The defaults are the linear schedule | |
| # kappa_t = t and kappa_dot_t = 1. A mask source returns the index of the mask symbol at every | |
| # position, with the network's vocabulary raised by one to make room for it. | |
| class DiscreteFlowMatching: | |
| """The source, the mixture path, the cross-entropy loss and one sampling step.""" | |
| def __init__(self, V, kappa=lambda t: t, kappa_dot=lambda t: torch.ones_like(t), mask=None): | |
| self.V, self.k, self.kd = V, kappa, kappa_dot # Alphabet size and the schedule. | |
| self.mask = mask # Mask token id under the mask source, None under the uniform source. | |
| def source(self, shape, device="cpu"): | |
| if self.mask is None: | |
| return torch.randint(0, self.V, shape, device=device) # Uniform source, [B, L]. | |
| return torch.full(shape, self.mask, dtype=torch.long, device=device) # All-mask source. | |
| def corrupt(self, x1, t): | |
| """One draw from p_t(. | x_0, x_1) by the coin flip of Section 5.5.5. x1: [B, L], t: [B].""" | |
| x0 = self.source(x1.shape, x1.device) # The other end of the pair; [B, L]. | |
| heads = torch.rand(x1.shape, device=x1.device) < self.k(t)[:, None] # Heads with prob kappa_t. | |
| return torch.where(heads, x1, x0) # The data token on heads and the source token on tails. | |
| # %% 03. The conditional velocity | |
| # | |
| # If we differentiate the conditional path at one position we get kappa_dot_t times the difference | |
| # of the two indicators. We then divide that flux by the probability 1 - kappa_t still sitting on | |
| # the source token, so the flux becomes a rate out of the token the position currently holds. | |
| def conditional_velocity(z, x1, t, V=DNA_SIZE): | |
| """u_t(a, z | x_1) = kappa_dot/(1 - kappa) [delta_{x_1}(a) - delta_z(a)]. Returns [V].""" | |
| factor = 1.0 / (1.0 - t) # Schedule factor on the linear schedule, kappa_dot_t = 1. | |
| return factor * (F.one_hot(torch.tensor(x1), V) - F.one_hot(torch.tensor(z), V)).double() | |
| # %% 04. The posterior average, collapsed onto the data token | |
| # | |
| # The conditional velocity depends on the pair only through the data token at this position, so every | |
| # pair sharing that token contributes the same term and the sum over pairs collapses onto the four | |
| # tokens. That leaves the denoiser p_{1|t}(. | x), the only quantity a network has to learn. | |
| def marginal_velocity(p1, x_i, t, V=DNA_SIZE): | |
| """u^i_t(., x) = kappa_dot/(1 - kappa) [p_{1|t}(. | x) - delta_{x^i}]. p1: [V] -> [V].""" | |
| factor = 1.0 / (1.0 - t) # Same schedule factor, now multiplying a distribution. | |
| return factor * (p1 - F.one_hot(torch.tensor(x_i), V).double()) # Signed, sums to zero. | |
| def mask_posterior(data, weights, visible, kappa_t): | |
| """The posterior over the data token at the masked position, computed by enumeration. | |
| ``data`` is a list of candidate sequences and ``weights`` their probabilities under q. Position 0 | |
| is visible and holds ``visible``; position 1 is masked. The time factor is common to every | |
| candidate and cancels, Proposition 6 of Discrete Flow Matching. | |
| """ | |
| masked, seen = 1, 1 # One masked position and one visible position in this example. | |
| factor = (1.0 - kappa_t) ** masked * kappa_t ** seen # Common to every surviving candidate. | |
| unnormalized = torch.zeros(DNA_SIZE, dtype=torch.double) # One entry per base at position 1. | |
| for sequence, weight in zip(data, weights): | |
| if sequence[0] == visible: # Drop any candidate that disagrees with the visible token. | |
| unnormalized[encode_dna([sequence])[0, 1]] += factor * weight | |
| return unnormalized / unnormalized.sum() # [V] the denoiser at the masked position. | |
| # %% 05. The cross-entropy loss and its minimizer | |
| # | |
| # The loss scores the true data token at every position, with no time weight and no rate in it, so we | |
| # choose the sampler after training. If we average over the data token given X_t, the expected loss | |
| # splits into the entropy of the true denoiser and a KL divergence from it to the network, and only | |
| # the divergence depends on theta. | |
| def dfm_loss(logits, x1): | |
| """Cross-entropy of Section 5.9, with the class dimension second. logits: [B, L, V].""" | |
| return F.cross_entropy(logits.transpose(1, 2), x1) # Mean over the batch and over positions. | |
| def entropy_and_gap(posterior, prediction): | |
| """The minimum of the loss at one position and the excess cost of a wrong prediction, in nats.""" | |
| entropy = -(posterior * posterior.log()).sum() # H(p_{1|t}), the floor no network beats. | |
| cross = -(posterior * prediction.log()).sum() # The cross-entropy the prediction scores. | |
| return float(entropy), float(cross - entropy) # The gap is the KL divergence to the prediction. | |
| # %% 06. The Euler step as a two-part rule | |
| # | |
| # If we substitute the marginal velocity into the per-position Euler step, the two terms collect | |
| # into a mixture of the current point mass and the denoiser. With probability lambda_t the position redraws | |
| # from the denoiser, and with probability 1 - lambda_t it keeps the token it holds. | |
| def redraw_probability(t, h): | |
| """lambda_t = h kappa_dot_t / (1 - kappa_t), the redraw probability of Section 5.8.""" | |
| return h / (1.0 - t) # Linear schedule; above one the sampler redraws with certainty. | |
| def dfm_step(proc, model, xt, t, h): | |
| """One two-part step at every position at once. xt: [B, L], t: [B], h a scalar.""" | |
| p1 = model(xt, t)[..., :proc.V].softmax(-1) # [B, L, V] denoiser, the only network call. | |
| rate = proc.kd(t) / (1 - proc.k(t)).clamp(min=1e-4) # kappa_dot/(1 - kappa), kept finite at t=1. | |
| redraw = torch.rand(xt.shape, device=xt.device) < (h * rate)[:, None] # [B, L] Bernoulli. | |
| x1 = Categorical(probs=p1).sample() # [B, L] one candidate token per position. | |
| return torch.where(redraw, x1, xt) # Keep the candidate where the coin came up true. | |
| # %% 07. Training and generation | |
| # | |
| # A training step draws one time per sequence, corrupts the batch at that time, and scores the true | |
| # tokens. To generate we walk a uniform grid of ``sample_steps`` times from t = 0 to t = 1, and the | |
| # grid is free to change because no step size enters the training loss. | |
| def generate(proc, model, batch, length, steps, device="cpu"): | |
| """Run the sampler from the source to t = 1 and return the tokens. [B, L].""" | |
| x = proc.source((batch, length), device) # Draw from the source distribution p. | |
| grid = torch.linspace(0.0, 1.0, steps + 1, device=device) # The sampling times. | |
| for t, s in zip(grid[:-1], grid[1:]): | |
| x = dfm_step(proc, model, x, t.expand(batch), s - t) # One forward pass per step. | |
| return x | |
| # %% 08. The chapter's worked numbers | |
| def worked_numbers(): | |
| """The arithmetic Sections 5.5.5, 5.7.4, 5.8, 5.9.2 and 5.9.3 carry out by hand.""" | |
| report = {} | |
| # Section 5.5.5: x_1 = ACGT, a uniform source draw x_0 = GTTA, kappa_t = 0.5, coins HTHT. | |
| x1, x0 = encode_dna(["ACGT"])[0], encode_dna(["GTTA"])[0] # [4] each. | |
| heads = torch.tensor([True, False, True, False]) # The four coins of the example. | |
| xt = torch.where(heads, x1, x0) # Data token on heads, source token on tails. | |
| report["mixture_draw"] = decode_dna(xt[None])[0] # ATGA. | |
| # Section 5.7.4: one position holding G, denoiser (0.1, 0.6, 0.2, 0.1), t = 0.5. | |
| p1 = torch.tensor([0.1, 0.6, 0.2, 0.1], dtype=torch.double) # [V] over A, C, G, T. | |
| u = marginal_velocity(p1, x_i=2, t=0.5) # Factor 1/0.5 = 2 times the bracket. | |
| report["velocity_at_G"] = [round(float(v), 4) for v in u] # (0.2, 1.2, -1.6, 0.2). | |
| report["velocity_sums_to"] = f"{float(u.sum()):.1e}" # Zero, the second rule. | |
| step = F.one_hot(torch.tensor(2), DNA_SIZE).double() + 0.1 * u # One Euler step of size h = 0.1. | |
| report["euler_step_at_G"] = [round(float(v), 4) for v in step] # (0.02, 0.12, 0.84, 0.02). | |
| report["redraw_probability"] = round(redraw_probability(0.5, 0.1), 4) # lambda_t = 0.2. | |
| # Section 5.9.2: the noisy input ATGA scored against the target ACGT. | |
| scored = torch.tensor([0.7, 0.2, 0.6, 0.1], dtype=torch.double) # One probability per position. | |
| report["example_loss_nats"] = round(float(-scored.log().sum()), 3) # 4.780 nats. | |
| # Section 5.9.3: length two, the mask source, q(AC) = 0.5, q(AG) = 0.3, q(GC) = 0.2. | |
| candidates, weights = ["AC", "AG", "GC"], [0.5, 0.3, 0.2] | |
| half = mask_posterior(candidates, weights, visible="A", kappa_t=0.5) # Denoiser at kappa = 0.5. | |
| late = mask_posterior(candidates, weights, visible="A", kappa_t=0.8) # The same at kappa = 0.8. | |
| report["mask_posterior_C_G"] = [round(float(half[1]), 4), round(float(half[2]), 4)] | |
| report["mask_posterior_unchanged"] = bool(torch.allclose(half, late)) # The time cancels. | |
| live = half[half > 0] # The two bases the posterior keeps. | |
| entropy, gap = entropy_and_gap(live, torch.tensor([0.5, 0.5], dtype=torch.double)) | |
| report["posterior_entropy_nats"] = round(entropy, 4) # 0.6616, the floor on the loss. | |
| report["kl_gap_to_uniform"] = round(gap, 4) # 0.0316 above that floor. | |
| return report | |
| # %% 09. The report | |
| def main(): | |
| parser = common_parser(__doc__.split("\n")[0]) | |
| parser.add_argument("--source", choices=["uniform", "mask"], default="uniform") | |
| parser.set_defaults(steps=1000, width=64, samples=64) | |
| args = parser.parse_args() | |
| set_seed(args.seed) | |
| splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed) | |
| data = splits["train_tokens"] # [N, L] the shared synthetic DNA. | |
| mask = DNA_SIZE if args.source == "mask" else None # Index 4 is the mask symbol m. | |
| vocab = DNA_SIZE + 1 if mask is not None else DNA_SIZE # The network's vocabulary. | |
| proc = DiscreteFlowMatching(DNA_SIZE, mask=mask) # Linear schedule by default. | |
| model = SequenceDenoiser(vocab, args.length, width=args.width, depth=2) | |
| def loss_fn(step): | |
| rows = torch.randint(0, len(data), (args.batch_size,)) # One minibatch of data sequences. | |
| x1 = data[rows] # [B, L] the data end of the pair. | |
| t = torch.rand(args.batch_size) # One time per sequence, uniform on [0, 1]. | |
| xt = proc.corrupt(x1, t) # [B, L] a draw from the conditional path. | |
| return dfm_loss(model(xt, t)[..., :DNA_SIZE], x1) # Score the true token at every position. | |
| if not args.quiet: | |
| print(f"source={args.source} vocab={vocab} parameters={count_parameters(model):,}") | |
| losses = train(model, loss_fn, steps=args.steps, lr=args.lr, quiet=args.quiet) | |
| tokens = generate(proc, model, args.samples, args.length, args.sample_steps) | |
| report = worked_numbers() | |
| report["final_loss_nats"] = sum(losses[-20:]) / len(losses[-20:]) # Averaged over the last steps. | |
| report["gc_fraction"] = gc_fraction(tokens) # The training data sit at 0.577. | |
| report["motif_fraction"] = motif_fraction(tokens) # Sequences carrying the planted ACGT. | |
| report["unique_fraction"] = unique_fraction(tokens) # Catches a collapsed sampler. | |
| report["token_entropy_nats"] = token_entropy(tokens, DNA_SIZE) # At most log 4 = 1.386. | |
| print_report(f"discrete flow matching, {args.source} source", report, decode_dna(tokens)) | |
| save_run(args.out, model, vars(args), report, losses, decode_dna(tokens)) | |
| if __name__ == "__main__": | |
| main() | |