Download lecture_6/categorical_map.py from ChatterjeeLab/CIS6270: direct link, hf CLI and curl.
- Browser
- Download file 13.1 kB
-
https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_6/categorical_map.py
- Command line
-
hf download hf://ChatterjeeLab/CIS6270/lecture_6/categorical_map.py
-
curl -L -o categorical_map.py https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_6/categorical_map.py
13.1 kB
| """Categorical Flow Maps, the Lagrangian identity on the simplex and the bound that replaces it. | |
| A softmax head makes the two-time prediction a distribution at every pair of times, and the | |
| Lagrangian identity rewritten in those coordinates reads pi_{s,t} = pi_{t,t}(X_{s,t}) minus a | |
| coefficient times the end-time derivative of the prediction. The derivative has no sign | |
| constraint, so the residual splits into a teacher gap, a difference of two distributions, and a | |
| drift, the scaled derivative. A parallelogram bound and Pinsker's inequality turn the squared | |
| residual into four KL divergences plus two squared drifts, and at the exact map the two pieces | |
| are antiparallel, so the bound is strictly positive where the residual is zero. | |
| Chapter 6, Section 6.28 of the notes. Trained on the four-token phrases of | |
| ``cis6270.data.phrases``. Prints the chapter's worked cancellation on three tokens, where the gap | |
| is the exact negative of the drift and the bound reads 0.199 around a residual of zero, and then | |
| two models trained on the residual and on its bound. | |
| python lecture_6/categorical_map.py # two models, 1000 steps each | |
| python lecture_6/categorical_map.py --steps 20 # a few seconds, for a first look | |
| """ | |
| # %% 01. Imports and configuration | |
| import torch | |
| from cis6270.data import phrases | |
| from cis6270.metrics import 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_PRIOR = (0.5, 0.3, 0.2) # The three data probabilities of the chapter's worked example. | |
| BOOK_SOURCE = (-0.5739, -0.8344, -0.0584) # The source draw I_0 the chapter starts from. | |
| BOOK_STATES = 3000 # States the chapter averages the two terms of the bound over. | |
| # %% 02. The exact model on three tokens | |
| # | |
| # With a Gaussian source and the linear interpolant, the posterior over the data token is a | |
| # softmax whose only state dependence is the coordinate of that token. The velocity follows from | |
| # D_t(x) = x + (1-t) b(x,t), and the flow integrates to twelve digits with Runge-Kutta. | |
| def exact_denoiser(x, t, prior): | |
| """pi_{t,t}(x), the posterior over the data token given the state. [B, K].""" | |
| return torch.softmax(prior.log() + t * x / (1.0 - t) ** 2, dim=-1) | |
| def exact_velocity(x, t, prior): | |
| """b(x,t) = (pi_{t,t}(x) - x)/(1 - t) for the linear interpolant. [B, K].""" | |
| return (exact_denoiser(x, t, prior) - x) / (1.0 - t) | |
| def exact_flow(x, s, t, prior, n: int = 2000): | |
| """X_{s,t}(x) by fourth-order Runge-Kutta on the exact velocity.""" | |
| h = (t - s) / n | |
| for k in range(n): | |
| u = s + k * h | |
| k1 = exact_velocity(x, u, prior) | |
| k2 = exact_velocity(x + 0.5 * h * k1, u + 0.5 * h, prior) | |
| k3 = exact_velocity(x + 0.5 * h * k2, u + 0.5 * h, prior) | |
| k4 = exact_velocity(x + h * k3, u + h, prior) | |
| x = x + (h / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4) | |
| return x | |
| def exact_two_time(x, s, t, prior): | |
| """pi_{s,t}(x) read off the convex parameterization, [(1-s) X_{s,t}(x) - (1-t) x]/(t-s).""" | |
| return ((1.0 - s) * exact_flow(x, s, t, prior) - (1.0 - t) * x) / (t - s) | |
| # %% 03. The coefficient, the gap and the drift | |
| # | |
| # gamma_{s,t} = (t-s)(1-t)/(1-s) vanishes on the diagonal and at t = 1 and never exceeds 1/4 on | |
| # the square. The residual scaled by the remaining time splits as (1-t) r = a + b, with a the gap | |
| # between the student's prediction and the prediction at the transported state. | |
| def gamma_coefficient(s, t): | |
| """gamma_{s,t} = (t-s)(1-t)/(1-s).""" | |
| return (t - s) * (1.0 - t) / (1.0 - s).clamp(min=1e-6) | |
| def end_time_derivative(x, s, t, prior, n: int = 2000): | |
| """d pi_{s,t}/dt from the integral representation, (1-s)/((t-s)(1-t)) times the bracket.""" | |
| transported = exact_flow(x, s, t, prior, n) | |
| prediction = ((1.0 - s) * transported - (1.0 - t) * x) / (t - s) | |
| at_output = exact_denoiser(transported, t, prior) | |
| return (1.0 - s) / ((t - s) * (1.0 - t)) * (at_output - prediction), prediction, at_output | |
| # %% 04. The worked cancellation of Section 6.28 | |
| # | |
| # The derivative above comes from the closed form of Section 6.26, and that form already carries | |
| # the cancellation, so the central difference of pi_{s,t} in t below checks the two pieces | |
| # against each other without assuming the identity. | |
| def worked_cancellation(s: float = 0.2, t: float = 0.6): | |
| """The three-token example, with every number integrated or differentiated here.""" | |
| prior = torch.tensor(BOOK_PRIOR, dtype=torch.float64) | |
| source = torch.tensor(BOOK_SOURCE, dtype=torch.float64) | |
| x = (1.0 - s) * source + s * torch.eye(3, dtype=torch.float64)[0] # Data token is the first. | |
| derivative, prediction, at_output = end_time_derivative(x, s, t, prior) | |
| gamma = (t - s) * (1.0 - t) / (1.0 - s) | |
| gap = prediction - at_output # a_{s,t}, a difference of two simplex points. | |
| drift = gamma * derivative # b_{s,t}, the scaled derivative of the prediction. | |
| divergence = (at_output * (at_output / prediction).log()).sum() # KL(teacher || student). | |
| difference = (exact_two_time(x, s, t + 1e-5, prior) | |
| - exact_two_time(x, s, t - 1e-5, prior)) / 2e-5 # A central check. | |
| return { | |
| "state x": [round(v, 4) for v in x.tolist()], | |
| "X_s,t(x)": [round(v, 4) for v in exact_flow(x, s, t, prior).tolist()], | |
| "pi_s,t(x)": [round(v, 4) for v in prediction.tolist()], | |
| "pi_t,t(X_s,t(x))": [round(v, 4) for v in at_output.tolist()], | |
| "gamma_s,t": gamma, | |
| "d pi / dt": [round(v, 4) for v in derivative.tolist()], | |
| "central difference": [round(v, 4) for v in difference.tolist()], | |
| "gap a": [round(v, 3) for v in gap.tolist()], | |
| "drift b": [round(v, 3) for v in drift.tolist()], | |
| "residual |a + b|^2": float(((gap + drift) ** 2).sum()), | |
| "4 KL": 4.0 * float(divergence), | |
| "2 |b|^2": 2.0 * float((drift ** 2).sum()), | |
| "the bound": 4.0 * float(divergence) + 2.0 * float((drift ** 2).sum()), | |
| "teacher entropy": -float((at_output * at_output.log()).sum()), | |
| "cross-entropy": -float((at_output * prediction.log()).sum()), | |
| } | |
| def averaged_bound(count: int = BOOK_STATES, s: float = 0.2, t: float = 0.6, seed: int = 0, | |
| n: int = 400): | |
| """The same two terms averaged over states drawn from the interpolant at the same times.""" | |
| prior = torch.tensor(BOOK_PRIOR, dtype=torch.float64) | |
| generator = torch.Generator().manual_seed(seed) | |
| which = torch.multinomial(prior.expand(count, 3), 1, generator=generator).squeeze(1) | |
| x1 = torch.eye(3, dtype=torch.float64)[which] # [N, 3] the lifted data token. | |
| x0 = torch.randn(count, 3, generator=generator, dtype=torch.float64) # [N, 3] the source. | |
| x = (1.0 - s) * x0 + s * x1 # [N, 3] the interpolant at the start time. | |
| derivative, prediction, at_output = end_time_derivative(x, s, t, prior, n) | |
| drift = ((t - s) * (1.0 - t) / (1.0 - s)) * derivative | |
| divergence = (at_output * (at_output / prediction).log()).sum(-1).mean() | |
| return {"averaged 4 KL": 4.0 * float(divergence), | |
| "averaged 2 |b|^2": 2.0 * float((drift ** 2).sum(-1).mean()), | |
| "averaged residual": float(((prediction - at_output + drift) ** 2).sum(-1).mean())} | |
| # %% 05. The student, and the two losses it can be trained on | |
| def predict(model, x, s, t, length, vocab): | |
| """pi_{s,t}(x) at every position, a softmax head over the vocabulary.""" | |
| return torch.softmax(model(x.flatten(1), s, t).view(-1, length, vocab), dim=-1) | |
| def pieces(model, xs, s, t, length, vocab): | |
| """The gap a and the drift b of the split, with the teacher detached.""" | |
| f = lambda tt: predict(model, xs, s, tt, length, vocab) | |
| prediction, derivative = torch.func.jvp(f, (t,), (torch.ones_like(t),)) # One forward mode pass. | |
| keep, move = (1.0 - t) / (1.0 - s), (t - s) / (1.0 - s) | |
| transported = keep[:, None, None] * xs + move[:, None, None] * prediction # X_{s,t}(x). | |
| teacher = predict(model, transported.detach(), t, t, length, vocab).detach() # pi_{t,t}. | |
| drift = gamma_coefficient(s, t)[:, None, None] * derivative | |
| return prediction - teacher, drift, teacher, prediction | |
| def make_loss(model, data, length, vocab, batch_size, device, bounded: bool): | |
| eye = torch.eye(vocab, device=device) | |
| def loss_fn(step: int) -> torch.Tensor: | |
| index = torch.randint(0, len(data), (batch_size,), device=device) | |
| x1, ids = eye[data[index]], data[index] # [B, L, K] and [B, L]. | |
| x0 = torch.randn_like(x1) # [B, L, K] the Gaussian source. | |
| t_diag = torch.rand(batch_size, device=device) | |
| xt = (1 - t_diag[:, None, None]) * x0 + t_diag[:, None, None] * x1 | |
| anchor = -(predict(model, xt, t_diag, t_diag, length, vocab).clamp_min(1e-9).log() | |
| .gather(-1, ids[..., None])).mean() # Cross-entropy on the diagonal. | |
| s = 0.9 * torch.rand(batch_size, device=device) # [B] start time, kept off t = 1. | |
| t = s + (0.95 - s) * torch.rand(batch_size, device=device) # [B] end time below one. | |
| xs = (1 - s[:, None, None]) * x0 + s[:, None, None] * x1 | |
| gap, drift, teacher, prediction = pieces(model, xs, s, t, length, vocab) | |
| if bounded: # Four KL divergences plus two squared drifts, the bound of Section 6.28. | |
| divergence = (teacher * (teacher.clamp_min(1e-9) | |
| / prediction.clamp_min(1e-9)).log()).sum(-1).mean() | |
| return anchor + 4.0 * divergence + 2.0 * (drift ** 2).sum(-1).mean() | |
| return anchor + ((gap + drift) ** 2).sum(-1).mean() # The residual itself. | |
| return loss_fn | |
| # %% 06. Sampling, and the test for a valid phrase | |
| def sample(model, count, length, vocab, steps, device): | |
| """Walk a uniform grid with the convex form and read the phrase off the last prediction.""" | |
| x = torch.randn(count, length, vocab, device=device) | |
| grid = torch.linspace(0.0, 1.0, steps + 1) | |
| for a, b in zip(grid[:-1], grid[1:]): | |
| s, t = a.expand(count).to(device), b.expand(count).to(device) | |
| prediction = predict(model, x, s, t, length, vocab) | |
| keep, move = (1.0 - t) / (1.0 - s), (t - s) / (1.0 - s) | |
| x = keep[:, None, None] * x + move[:, None, None] * prediction | |
| return x.argmax(-1) # [N, L]. | |
| def valid_fraction(drawn, group: int = 4): | |
| """A phrase is in the language when position i holds a token from the i-th quarter.""" | |
| slots = torch.arange(drawn.shape[1], device=drawn.device) # [L]. | |
| return float(((drawn // group) == slots[None, :]).all(dim=1).to(torch.get_default_dtype()).mean()) | |
| # %% 07. The report | |
| def main() -> None: | |
| parser = common_parser(__doc__.split("\n")[0]) | |
| parser.set_defaults(steps=1000, batch_size=128, samples=256, sample_steps=4) | |
| args = parser.parse_args() | |
| set_seed(args.seed) | |
| device = pick_device(args.device) | |
| tokens, vocabulary = phrases(num_phrases=1024, seed=args.seed) | |
| data, length, vocab = tokens.to(device), tokens.shape[1], len(vocabulary) | |
| report = worked_cancellation() | |
| report.update(averaged_bound(seed=args.seed)) # The chapter's three thousand states. | |
| if not args.quiet: | |
| print(f"gap {report['gap a']} and drift {report['drift b']} cancel to " | |
| f"{report['residual |a + b|^2']:.2e}, bound {report['the bound']:.3f}") | |
| samples: list[str] = [] | |
| for name, bounded in (("the residual", False), ("the bound", True)): | |
| set_seed(args.seed) # Both students start from the same weights and see the same batches. | |
| model = TwoTimeMLP(dim=length * vocab, width=args.width).to(device) | |
| if not args.quiet: | |
| print(f"\ntraining on {name} parameters={count_parameters(model):,}") | |
| losses = train(model, make_loss(model, data, length, vocab, args.batch_size, device, | |
| bounded), steps=args.steps, lr=args.lr, quiet=args.quiet) | |
| for steps in (1, args.sample_steps): | |
| torch.manual_seed(args.seed + 1) | |
| drawn = sample(model, args.samples, length, vocab, steps, device) | |
| report[f"{name}, {steps} step(s): valid"] = valid_fraction(drawn) | |
| report[f"{name}, {steps} step(s): distinct"] = unique_fraction(drawn) | |
| report[f"{name}, {steps} step(s): entropy"] = token_entropy(drawn, vocab) | |
| report[f"{name}: final loss"] = sum(losses[-20:]) / len(losses[-20:]) | |
| drawn = sample(model, 4, length, vocab, args.sample_steps, device) | |
| samples.append(f"{name:<14s} " + " | ".join( | |
| " ".join(vocabulary[int(i)] for i in row) for row in drawn[:2])) | |
| print_report("Categorical Flow Maps, Chapter 6", report, samples) | |
| save_run(args.out, config=vars(args), report=report, samples=samples) | |
| if __name__ == "__main__": | |
| main() | |