Download lecture_5/velocity.py from ChatterjeeLab/CIS6270: direct link, hf CLI and curl.
- Browser
- Download file 10.8 kB
-
https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_5/velocity.py
- Command line
-
hf download hf://ChatterjeeLab/CIS6270/lecture_5/velocity.py
-
curl -L -o velocity.py https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_5/velocity.py
10.8 kB
| """A jump rate written as a velocity, and the master equation it satisfies. | |
| Nothing here is learned. A velocity on a finite set is a rate matrix read from the destination's | |
| side, and two rules fix which tables of numbers qualify. The master equation is the condition under | |
| which such a table generates a probability path. The schedule factor of the mixture path turns out | |
| to be a hazard rate whose survival curve is the path itself. The discrete velocity has the same | |
| shape as the continuous one of Chapter 2, with a posterior distribution in place of a posterior | |
| mean. | |
| 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.2, 5.3, 5.6.3 and 5.8.1. The long integration starts from the letter frequency | |
| of the synthetic DNA of ``cis6270.data``. Prints the chapter's worked velocity, its Euler step, the | |
| hazard at two times, and the two velocities side by side. | |
| python lecture_5/velocity.py # 600 Euler steps in the long integration | |
| python lecture_5/velocity.py --steps 20 --quiet # a coarser grid, visibly less accurate | |
| """ | |
| # %% 01. Imports and configuration | |
| import math | |
| import torch | |
| import torch.nn.functional as F | |
| from cis6270.data import DNA, DNA_SIZE, dna_splits | |
| from cis6270.runner import common_parser, print_report, save_run, set_seed | |
| torch.set_default_dtype(torch.double) # Full precision, because the report prints six decimal places. | |
| # %% 02. A jump rate written as a velocity | |
| # | |
| # Chapter 4 wrote R_t(x, y) for the rate of jumping from x to y. Discrete flow matching writes the | |
| # same number with the destination first, u_t(y, x) = R_t(x, y). If we fix x and let y vary, we get | |
| # one vector attached to the state x, with one component per destination. | |
| def velocity_from_rates(rates): | |
| """Transpose a Chapter 4 rate matrix R_t(x, y) into a Chapter 5 velocity u_t(y, x).""" | |
| return rates.transpose(0, 1) # [K, K] with the destination now indexing the row. | |
| def jump_to_one_state(target, size=DNA_SIZE, rate=1.0): | |
| """Every state other than ``target`` leaves toward it at ``rate``, and ``target`` never leaves.""" | |
| u = torch.zeros(size, size) # [K, K], row = destination, column = current state. | |
| for x in range(size): | |
| if x == target: | |
| continue | |
| u[target, x] = rate # The one positive off-diagonal entry in column x. | |
| u[x, x] = -rate # The diagonal is minus the total rate of leaving x. | |
| return u | |
| # %% 03. The two rules a velocity obeys | |
| # | |
| # Off the diagonal the entries are nonnegative, since probability cannot flow out of a state at a | |
| # negative rate. Every column sums to zero, since probability leaving one state has to arrive at | |
| # another. Once we choose the off-diagonal entries, the two rules fix the diagonal. | |
| def obeys_the_two_rules(u, tolerance=1e-9): | |
| """True when every off-diagonal entry is nonnegative and every column sums to zero.""" | |
| off = u - torch.diag(torch.diagonal(u)) # The matrix with the diagonal removed. | |
| return bool((off >= -tolerance).all() and (u.sum(dim=0).abs() < tolerance).all()) | |
| def set_diagonal(u): | |
| """u_t(x, x) = -sum_{y != x} u_t(y, x), the total rate of leaving x.""" | |
| u = u.clone() | |
| u.fill_diagonal_(0.0) # Keep the off-diagonal entries and discard whatever the diagonal held. | |
| return u - torch.diag(u.sum(dim=0)) # Each column now sums to zero. | |
| # %% 04. The master equation in velocity form | |
| # | |
| # The rate of change of p_t(y) is the velocity into y times the probability of each current state, | |
| # summed over states. As a matrix equation this is p_dot = u p, where p is a column vector. This | |
| # chapter writes the velocity in the column convention, so the product needs no transpose. | |
| def master_equation(u, p): | |
| """p_dot_t(y) = sum_x u_t(y, x) p_t(x). u: [K, K], p: [K] -> [K].""" | |
| return u @ p # The matrix multiplies the probability vector from the left. | |
| def arrivals_and_departures(u, p, y): | |
| """The flux arriving at y from every other state, and the flux leaving y, as two numbers.""" | |
| others = [x for x in range(len(p)) if x != y] # Every state except y itself. | |
| arriving = sum(float(u[y, x] * p[x]) for x in others) # u_t(y, x) p_t(x) along each edge in. | |
| leaving = sum(float(u[x, y] * p[y]) for x in others) # u_t(x, y) p_t(y) along each edge out. | |
| return arriving, leaving # Their difference is p_dot_t(y). | |
| def integrate(u, p, steps): | |
| """Carry p forward by ``steps`` Euler steps over one unit of time under a constant velocity.""" | |
| h = 1.0 / steps # The time increment of one step. | |
| for _ in range(steps): | |
| p = p + h * master_equation(u, p) # p_{t+h} = p_t + h p_dot_t. | |
| return p # [K] after one unit of time. | |
| # %% 05. One velocity on DNA, with numbers | |
| # | |
| # Let the token be a single DNA base, let every base other than G jump to G at rate 1, and let G | |
| # never leave. Because the two rules hold, probability moves into G and the total stays at one. | |
| def worked_master_equation(): | |
| """Section 5.3.2 with p_t = (0.1, 0.2, 0.3, 0.4) and the all-to-G velocity.""" | |
| u = jump_to_one_state(DNA.index("G")) # [4, 4] the matrix printed in the chapter. | |
| p = torch.tensor([0.1, 0.2, 0.3, 0.4]) # [4] over A, C, G, T. | |
| p_dot = master_equation(u, p) # (-0.1, -0.2, 0.7, -0.4). | |
| arriving, leaving = arrivals_and_departures(u, p, DNA.index("G")) # 0.7 in and 0 out. | |
| return u, p, p_dot, arriving, leaving | |
| # %% 06. Survival and hazard | |
| # | |
| # The schedule factor kappa_dot_t/(1 - kappa_t) is a hazard rate, and the probability that a position | |
| # still holds its source token is its survival curve. If we separate variables we get | |
| # S(t) = 1 - kappa_t, the same weight the mixture path puts on the source token. | |
| def hazard(t): | |
| """kappa_dot_t/(1 - kappa_t) on the linear schedule, or 1/(1 - t).""" | |
| return 1.0 / (1.0 - t) # 1 at t = 0, 2 at t = 0.5, 10 at t = 0.9. | |
| def survival(steps, upper=0.9): | |
| """Integrate dS/dt = -hazard(t) S(t) from S(0) = 1 and return S at ``upper``.""" | |
| h, s = upper / steps, 1.0 # One increment and the initial survival probability. | |
| for k in range(steps): | |
| s = s - h * hazard(k * h) * s # Euler on the survival equation. | |
| return s # Exactly 1 - kappa_t = 1 - upper, since the two factors telescope at every grid. | |
| # %% 07. The continuous and discrete velocities side by side | |
| # | |
| # In continuous space the velocity is the schedule factor times the posterior mean minus the current | |
| # point. On tokens it is the same factor times the posterior distribution minus the point mass on the | |
| # current token. One step of size h displaces the point by h u_t(x). On tokens the same step | |
| # redraws from the denoiser with probability lambda_t, and the two agree in expectation on the | |
| # one-hot embedding. | |
| def continuous_velocity(x, x1_hat, t): | |
| """u_t(x) = kappa_dot/(1 - kappa) [x_hat_{1|t}(x) - x] at a point of R^d. x: [d] -> [d].""" | |
| return hazard(t) * (x1_hat - x) # The displacement that remains, divided by the time remaining. | |
| def discrete_velocity(x_i, p1, t, size=DNA_SIZE): | |
| """u^i_t(., x) = kappa_dot/(1 - kappa) [p_{1|t}(. | x) - delta_{x^i}]. Returns [K].""" | |
| return hazard(t) * (p1 - F.one_hot(torch.tensor(x_i), size).double()) # Signed, sums to zero. | |
| def side_by_side(x_i, p1, t, h): | |
| """The continuous Euler step at the one-hot point and the discrete redraw distribution.""" | |
| x = F.one_hot(torch.tensor(x_i), len(p1)).double() # The current token as a corner of R^K. | |
| moved = x + h * continuous_velocity(x, p1, t) # Displace by h times the velocity. | |
| lam = h * hazard(t) # lambda_t, the redraw probability of the two-part rule. | |
| drawn = (1.0 - lam) * x + lam * p1 # Keep the current token, or redraw from the denoiser. | |
| return moved, drawn, lam # The two coincide, the last row of Table 5.1. | |
| # %% 08. The report | |
| def main(): | |
| parser = common_parser(__doc__.split("\n")[0]) | |
| args = parser.parse_args() | |
| set_seed(args.seed) | |
| u, p, p_dot, arriving, leaving = worked_master_equation() | |
| report = { | |
| "two_rules_hold": obeys_the_two_rules(u), # Nonnegative off the diagonal, columns sum to zero. | |
| "diagonal_recovered": bool(torch.allclose(u, set_diagonal(u))), # The rules fix the diagonal. | |
| "p_dot": [round(float(v), 4) for v in p_dot], # (-0.1, -0.2, 0.7, -0.4). | |
| "p_dot_sums_to": round(float(p_dot.sum()), 6), # Zero, so probability is conserved. | |
| "flux_into_G": round(arriving, 4), # 0.7, the three incoming rates against their probabilities. | |
| "flux_out_of_G": round(leaving, 4), # 0, since G has no outgoing rate. | |
| "euler_step_h_0.1": [round(float(v), 4) for v in p + 0.1 * p_dot], # (0.09, 0.18, 0.37, 0.36). | |
| "hazard_at_0.5": round(hazard(0.5), 4), # 2. | |
| "hazard_at_0.9": round(hazard(0.9), 4), # 10. | |
| "survival_at_0.9": f"{survival(args.steps):.6f}", # 1 - 0.9 = 0.1 at any grid. | |
| "survival_exact": 0.1, # S(t) = 1 - kappa_t. | |
| } | |
| # Section 5.8.1: one position holding G with the denoiser of the chapter. | |
| p1 = torch.tensor([0.1, 0.6, 0.2, 0.1]) # [4] over A, C, G, T. | |
| moved, drawn, lam = side_by_side(DNA.index("G"), p1, t=0.5, h=0.1) | |
| report["discrete_velocity_at_G"] = [round(float(v), 4) for v in discrete_velocity(2, p1, 0.5)] | |
| report["continuous_step"] = [round(float(v), 4) for v in moved] # (0.02, 0.12, 0.84, 0.02). | |
| report["discrete_redraw"] = [round(float(v), 4) for v in drawn] # The identical vector. | |
| report["redraw_probability"] = round(lam, 4) # lambda_t = 0.2. | |
| # The same velocity run for a whole unit of time, starting from the data's letter frequency. | |
| splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed) | |
| counts = torch.bincount(splits["train_tokens"].flatten(), minlength=DNA_SIZE).double() | |
| start = counts / counts.sum() # [4] the empirical frequency of A, C, G, T in the training DNA. | |
| final = integrate(u, start, args.steps) # Euler on the master equation, args.steps increments. | |
| exact = start.clone() | |
| exact[:] = start * math.exp(-1.0) # Every base other than G decays as exp(-t). | |
| exact[DNA.index("G")] = 1.0 - (start.sum() - start[DNA.index("G")]) * math.exp(-1.0) | |
| report["dna_start"] = [round(float(v), 4) for v in start] # The GC-rich background of the data. | |
| report["dna_after_one_unit"] = [round(float(v), 4) for v in final] # Euler at args.steps. | |
| report["dna_exact"] = [round(float(v), 4) for v in exact] # The closed-form solution. | |
| report["euler_error"] = round(float((final - exact).abs().max()), 6) # Shrinks with args.steps. | |
| print_report("a jump rate written as a velocity", report) | |
| save_run(args.out, config=vars(args), report=report) | |
| if __name__ == "__main__": | |
| main() | |