"""Flow Map Language Models, the composition identity in denoiser coordinates. We lift each token to a one-hot vector, the source is Gaussian over that space, and the state moves through R^{L x K}, so no atom has to be split. If we step by the full remaining time 1 - s in place of the elapsed time t - s, the average velocity becomes a two-time denoiser that lands on the simplex. The map is then a convex combination of staying put and jumping to the prediction, and the cocycle is a convex combination of two shorter predictions. A KL divergence measures the gap. Chapter 6, Sections 6.25 to 6.27 of the notes. Trained on the synthetic DNA of ``cis6270.data.synthetic_dna``. Prints the convex weights, the composition weight gamma = 0.2 at (0.2, 0.5, 0.8), the worked KL of 0.030747 nats, the two gradients that decide the loss, the decoding clock of a 30,522-token vocabulary and the four steps it produces, and samples from the trained map. python lecture_6/fmlm.py # 1500 steps, about half a minute python lecture_6/fmlm.py --steps 20 # a few seconds, for a first look """ # %% 01. Imports and configuration import math import torch from cis6270.data import DNA_SIZE, decode_dna, synthetic_dna from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction from cis6270.nets import TwoTimeMLP, count_parameters from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train torch.set_num_threads(1) # These networks are small, and splitting one matrix product costs more # than it saves. ``tests/conftest.py`` sets the same thing for the test session, and this line # acts when a reader runs the script directly. BOOK_VOCAB = 30522 # The vocabulary the chapter puts on the clock, a standard tokenizer. ENDPOINT_FRACTION = 0.25 # Share of each batch drawn with t = 1, the slice the sampler reads. # %% 02. The convex form and the composition weight # # X_{s,t}(x) = (1-t)/(1-s) x + (t-s)/(1-s) delta_{s,t}(x). Both weights are non-negative for # s <= t <= 1 and they sum to one, so each step is a convex combination of staying put and jumping # to the predicted endpoint. At t = s the pair is (1, 0) and the identity axiom holds. def convex_weights(s, t): """The pair ((1-t)/(1-s), (t-s)/(1-s)), the weights on the state and on the prediction.""" remaining = (1.0 - s).clamp(min=1e-6) return (1.0 - t) / remaining, (t - s) / remaining def composition_weight(s, u, t): """gamma = (1-t)(u-s)/((1-u)(t-s)), the share of the long prediction made over the first leg.""" return (1.0 - t) * (u - s) / (((1.0 - u) * (t - s)).clamp(min=1e-6)) # %% 03. The cocycle in denoiser coordinates, checked on the scalar flow # # delta_{s,t}(x) = gamma delta_{s,u}(x) + (1-gamma) delta_{u,t}(X_{s,u}(x)). On dx/dt = x the # two-time denoiser is x[1 + (1-s)(e^{t-s}-1)/(t-s)], so every term is available in closed form. def scalar_denoiser(x, s, t): """delta_{s,t}(x) on the flow dx/dt = x.""" return x * (1.0 + (1.0 - s) * math.expm1(t - s) / (t - s)) def scalar_cocycle(x: float = 1.0, s: float = 0.2, u: float = 0.5, t: float = 0.8): """Every term of the denoiser cocycle at the chapter's triple of times.""" gamma = (1.0 - t) * (u - s) / ((1.0 - u) * (t - s)) first = scalar_denoiser(x, s, u) # The prediction over the first leg. reached = x * math.exp(u - s) # X_{s,u}(x), the state the first leg arrives at. second = scalar_denoiser(reached, u, t) # The prediction over the second leg. long = scalar_denoiser(x, s, t) # The prediction over the whole interval. keep, move = (1.0 - t) / (1.0 - s), (t - s) / (1.0 - s) return {"gamma": gamma, "delta_s,u": first, "delta_u,t": second, "delta_s,t": long, "two legs combined": gamma * first + (1.0 - gamma) * second, "convex weights": (round(keep, 4), round(move, 4)), "convex form": keep * x + move * long, "X_s,t(x)": x * math.exp(t - s)} # %% 04. One term of the loss, on the DNA alphabet # # The composed target is a convex combination of two simplex points, so it is a distribution and # the divergence against it needs no projection. Its gradient pushes mass onto the token with more # weight in the two-leg decomposition than in the long prediction. def worked_kl(): """The chapter's four-token example, with the target composed from two student predictions.""" first = torch.tensor([0.10, 0.20, 0.60, 0.10]) # delta_{0.2,0.5} over (A, C, G, T). second = torch.tensor([0.05, 0.10, 0.80, 0.05]) # delta_{0.5,0.8} at the state the leg reaches. gamma = (1.0 - 0.8) * (0.5 - 0.2) / ((1.0 - 0.5) * (0.8 - 0.2)) target = gamma * first + (1.0 - gamma) * second # [4] the composed target. student = torch.tensor([0.10, 0.15, 0.65, 0.10]) # The student's long prediction. terms = target * (target / student).log() # [4] the four terms of the divergence. return {"worked gamma": gamma, "composed target": [round(v, 4) for v in target.tolist()], "target mass": float(target.sum()), "worked KL": float(terms.sum()), "the four terms": [round(v, 6) for v in terms.tolist()]} # %% 05. The two gradients of a softmax head # # With a softmax head the KL gradient in the logits is q - p, so the size of the correction in # coordinate i is the error in coordinate i. The squared error passes through the softmax Jacobian # q_j(delta_ij - q_i) and carries a factor q_i in every term, so a token the model has learned to # rule out gets almost no correction. Here we take both gradients by automatic differentiation. # That checks the two expressions of Section 6.27 as well as evaluating them. def logit_gradients(target, student): """The gradient of KL(p || q) and of half the squared error in the logits of q. Two [K].""" for_kl = student.log().clone().requires_grad_(True) # Logits whose softmax is the student. divergence = (target * (target / torch.softmax(for_kl, -1)).log()).sum() kl_gradient, = torch.autograd.grad(divergence, for_kl) # [K] equal to q - p. for_squared = student.log().clone().requires_grad_(True) # A second graph on the same logits. squared = 0.5 * ((torch.softmax(for_squared, -1) - target) ** 2).sum() squared_gradient, = torch.autograd.grad(squared, for_squared) # [K] with a q_i throughout. return kl_gradient, squared_gradient def gradient_comparison(floor: float = 1e-6): """The two gradients at the chapter's student, and again once that student has saturated.""" target = torch.tensor([0.06, 0.12, 0.76, 0.06]) # p, the composed target of Section 6.27. student = torch.tensor([0.10, 0.15, 0.65, 0.10]) # q, the student's long prediction. kl, squared = logit_gradients(target, student) ratio = (squared / kl).abs() # Between a quarter and a half, coordinate by coordinate. rest = student[1:] * (1.0 - floor) / student[1:].sum() # The other three keep their shares. saturated = torch.cat([torch.full_like(student[:1], floor), rest]) # [K], summing to one. kl_low, squared_low = logit_gradients(target, saturated) return { "KL gradient": [round(v, 5) for v in kl.tolist()], "squared-error gradient": [round(v, 5) for v in squared.tolist()], "squared over KL": [round(v, 4) for v in ratio.tolist()], "saturated student": [round(v, 6) for v in saturated.tolist()], "saturated KL gradient": float(kl_low[0]), # The surviving squared-error gradient is of order 1e-08, below what four printed # decimals show, so the report carries it in units of 1e-08. "saturated squared-error gradient, in 1e-08": 1e8 * float(squared_low[0]), "orders of magnitude between them": math.log10( abs(float(kl_low[0])) / abs(float(squared_low[0]))), } # %% 06. The decoding clock # # A position decodes correctly when its data coordinate is the largest of the |V| coordinates, # a shifted standard normal against the maximum of the rest. The reparameterized time tau is the # fraction of the initial decoding error already removed, and the sampler runs on that clock. def decoding_error(t: float, vocab_size: int, nodes: int = 4001) -> float: """P_e(t), the probability that one position decodes to the wrong token at time t.""" if t >= 1.0: return 0.0 shift = t / (1.0 - t) # a = t/(1-t), the gap the data coordinate opens. z = torch.linspace(-12.0, 14.0, nodes, dtype=torch.float64) # Quadrature grid over the draw. density = torch.exp(-0.5 * z * z) / math.sqrt(2.0 * math.pi) # phi(z). cumulative = 0.5 * torch.erfc(-(z + shift) / math.sqrt(2.0)) # Phi(z + a). log_all = (vocab_size - 1) * cumulative.clamp(min=1e-300).log() # All the others fall short. return 1.0 - float(torch.trapz(density * log_all.exp(), z)) def clock(vocab_size: int): """tau(t) = (P_e(0) - P_e(t))/P_e(0), as a callable, with the source error rate beside it.""" start = decoding_error(0.0, vocab_size) return (lambda t: (start - decoding_error(t, vocab_size)) / start), start def even_in_tau(vocab_size: int, pieces: int = 4): """Solve tau(t) = k/pieces by bisection and return the grid with its two endpoints.""" tau, _ = clock(vocab_size) grid = [0.0] for k in range(1, pieces): low, high = 0.0, 1.0 - 1e-6 for _ in range(60): # Bisection; tau is increasing in t. middle = 0.5 * (low + high) low, high = (middle, high) if tau(middle) < k / pieces else (low, middle) grid.append(0.5 * (low + high)) return grid + [1.0] # %% 07. The model, a two-time denoiser with a softmax head def predict(model, x, s, t, length, vocab): """delta_{s,t}(x) at every position, a point of the simplex produced by a softmax head.""" logits = model(x.flatten(1), s, t).view(-1, length, vocab) # [B, L, K] the network output. return torch.softmax(logits, dim=-1) # [B, L, K] on the simplex at every position. def step_map(x, prediction, s, t): """The convex form, the flow map written in denoiser coordinates.""" keep, move = convex_weights(s, t) return keep[:, None, None] * x + move[:, None, None] * prediction # [B, L, K]. def make_loss(model, data, length, vocab, batch_size, device): eye = torch.eye(vocab, device=device) # The one-hot lift of the alphabet. def loss_fn(step: int) -> torch.Tensor: index = torch.randint(0, len(data), (batch_size,), device=device) x1 = eye[data[index]] # [B, L, K] the data, lifted. x0 = torch.randn_like(x1) # [B, L, K] the Gaussian source. t_diag = torch.rand(batch_size, device=device) # [B] a time for the diagonal term. xt = (1 - t_diag[:, None, None]) * x0 + t_diag[:, None, None] * x1 # [B, L, K]. diagonal = -(predict(model, xt, t_diag, t_diag, length, vocab).clamp_min(1e-9).log() .gather(-1, data[index][..., None])).mean() # Cross-entropy to the token. s = torch.rand(batch_size, device=device) # [B] start time. t = s + (1.0 - s) * torch.rand(batch_size, device=device) # [B] end time. # The slice t = 1 is the one-step case the sampler reads, and a continuous draw never # lands on it, so we put a fixed share of the batch there on purpose (Section 6.26). endpoint = torch.rand(batch_size, device=device) < ENDPOINT_FRACTION # [B] which rows. t = torch.where(endpoint, torch.ones_like(t), t) u = s + (t - s) * torch.rand(batch_size, device=device) # [B] the split point. xs = (1 - s[:, None, None]) * x0 + s[:, None, None] * x1 # [B, L, K] the interpolant. with torch.no_grad(): # Both legs and the intermediate state are frozen targets. first = predict(model, xs, s, u, length, vocab) second = predict(model, step_map(xs, first, s, u), u, t, length, vocab) gamma = composition_weight(s, u, t)[:, None, None] target = gamma * first + (1.0 - gamma) * second # [B, L, K], still on the simplex. student = predict(model, xs, s, t, length, vocab).clamp_min(1e-9) off_diagonal = (target * (target.clamp_min(1e-9) / student).log()).sum(-1).mean() return diagonal + off_diagonal return loss_fn # %% 08. Sampling on a grid of times @torch.no_grad() def sample(model, count, length, vocab, grid, device): """Walk the grid with the convex form, and read the alphabet off the last prediction.""" x = torch.randn(count, length, vocab, device=device) # [N, L, K] the Gaussian source. for a, b in zip(grid[:-1], grid[1:]): s = torch.full((count,), a, device=device) t = torch.full((count,), b, device=device) x = step_map(x, predict(model, x, s, t, length, vocab), s, t) return x.argmax(-1) # [N, L] token ids, read by an argmax at every position. # %% 09. The report def main() -> None: parser = common_parser(__doc__.split("\n")[0]) parser.set_defaults(steps=1500, batch_size=128, samples=64, length=16) args = parser.parse_args() set_seed(args.seed) device = pick_device(args.device) tokens, _ = synthetic_dna(num_sequences=1024, length=args.length, seed=args.seed) data = tokens.to(device) # [1024, L] the training sequences. report = scalar_cocycle() report.update(worked_kl()) report.update(gradient_comparison()) if not args.quiet: print(f"at the saturated student the KL gradient is " f"{report['saturated KL gradient']:.4f} and the squared-error gradient " f"{1e-8 * report['saturated squared-error gradient, in 1e-08']:.3e}") book_tau, book_start = clock(BOOK_VOCAB) for t in (0.60, 0.75, 0.80, 0.85): report[f"tau({t:.2f}) at |V|=30522"] = book_tau(t) book_grid = even_in_tau(BOOK_VOCAB) report["four steps on the clock"] = [round(t, 6) for t in book_grid] rounded = [round(t, 3) for t in book_grid] # The chapter quotes the grid to three decimals. report["convex weights on that grid"] = [ (round((1 - b) / (1 - a), 4), round((b - a) / (1 - a), 4)) for a, b in zip(rounded[:-1], rounded[1:])] report["gamma on the last three"] = float(composition_weight( *(torch.tensor([v]) for v in rounded[1:4]))) report["gamma over the long first leg"] = float(composition_weight( *(torch.tensor([v]) for v in (0.0, rounded[1], rounded[2])))) report["tau on a uniform grid"] = [round(book_tau(t), 5) for t in (0.25, 0.5, 0.75)] if not args.quiet: print(f"clock at |V|=30522: P_e(0) = {book_start:.6f}, four steps at " f"{[round(t, 3) for t in book_grid]}") model = TwoTimeMLP(dim=args.length * DNA_SIZE, width=args.width).to(device) if not args.quiet: print(f"\ntraining the two-time denoiser parameters={count_parameters(model):,}") losses = train(model, make_loss(model, data, args.length, DNA_SIZE, args.batch_size, device), steps=args.steps, lr=args.lr, quiet=args.quiet) dna_grid = even_in_tau(DNA_SIZE) # The same clock, read at the four-letter alphabet. report["DNA clock grid"] = [round(t, 4) for t in dna_grid] samples: list[str] = [] for name, grid in (("one step", [0.0, 1.0]), ("four, uniform", [0.0, 0.25, 0.5, 0.75, 1.0]), ("four, on the clock", dna_grid)): torch.manual_seed(args.seed + 1) # The same source noise for every schedule. drawn = sample(model, args.samples, args.length, DNA_SIZE, grid, device) report[f"{name}: GC fraction"] = gc_fraction(drawn) report[f"{name}: motif fraction"] = motif_fraction(drawn) report[f"{name}: token entropy"] = token_entropy(drawn, DNA_SIZE) report[f"{name}: unique fraction"] = unique_fraction(drawn) samples.append(f"{name:<20s} {decode_dna(drawn[:1])[0]} GC {gc_fraction(drawn):.3f}") report["training GC fraction"] = gc_fraction(data) report["final loss"] = sum(losses[-20:]) / len(losses[-20:]) print_report("Flow Map Language Models, Chapter 6", report, samples) save_run(args.out, model=model, config=vars(args), report=report, losses=losses, samples=samples) if __name__ == "__main__": main()