File size: 10,810 Bytes
0ba9d09 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 | """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()
|