CIS6270 / lecture_5 /velocity.py
pranamanam's picture
Upload 87 files
0ba9d09 verified
Raw History Blame Contribute Delete
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()