Download lecture_5/corrector.py from ChatterjeeLab/CIS6270: direct link, hf CLI and curl.
- Browser
- Download file 14.4 kB
-
https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_5/corrector.py
- Command line
-
hf download hf://ChatterjeeLab/CIS6270/lecture_5/corrector.py
-
curl -L -o corrector.py https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_5/corrector.py
14.4 kB
| """Correctors: extra motion that leaves the probability path where it is. | |
| The master equation is linear in the velocity, so we can add a second velocity with no net flux into | |
| any state without moving the marginals. Detailed balance is the easy way to arrange that, and the | |
| mixture path already contains such a term, the difference between the forward velocity and the same | |
| path run backward. If we add it with weight eta, a position unmasks more often and remasks in | |
| between, so the sampler gets more chances to replace a token the rest of the sequence has ruled out. | |
| 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.13 and 5.14. The long simulation uses the letter frequency of the synthetic DNA | |
| of ``cis6270.data`` as its denoiser. Prints the zero-flux check, the per-step unmasking table, the | |
| jump counts at three corrector strengths, the parallel-step error at 64 steps, and the chance a | |
| jointly revealed pair survives to t = 1 untouched. | |
| python lecture_5/corrector.py # 600 Euler steps on 40,000 positions | |
| python lecture_5/corrector.py --eta 10 # one corrector strength in the step table | |
| python lecture_5/corrector.py --steps 20 --quiet # a coarse grid, visibly biased jump counts | |
| """ | |
| # %% 01. Imports and configuration | |
| import math | |
| import torch | |
| from cis6270.data import DNA, DNA_SIZE, dna_splits | |
| from cis6270.runner import common_parser, print_report, save_run, set_seed | |
| EXACT = torch.double # The worked arithmetic keeps full precision whatever the default dtype. | |
| MASK = DNA_SIZE # Index 4 is the mask symbol m, outside the four-letter alphabet. | |
| # %% 02. Velocities that move samples without moving the path | |
| # | |
| # We can add a velocity v_t whose net flux into every state is zero to a velocity that generates | |
| # p_t, and the sum generates the same path. Detailed balance, equal flux in both directions along | |
| # every edge, is sufficient. If we reverse every term, the net flux becomes p_t(y) times a column of | |
| # rates, and those rates sum to zero. | |
| def net_flux(v, p): | |
| """sum_x v_t(y, x) p_t(x) at every state y. v: [K, K], p: [K] -> [K].""" | |
| return v @ p # Zero at every entry when v moves no probability. | |
| def detailed_balance_velocity(p, chosen): | |
| """Build v_t from chosen forward rates, with each reverse rate fixed by detailed balance. | |
| ``chosen`` maps an edge (x, y) to the rate v_t(y, x). The reverse rate is then | |
| v_t(x, y) = v_t(y, x) p_t(x) / p_t(y). This equalizes the flux along that edge. | |
| """ | |
| v = torch.zeros(len(p), len(p), dtype=p.dtype) # [K, K], destination indexing the row. | |
| for (x, y), rate in chosen.items(): | |
| v[y, x] = rate # The rate we pick, out of x toward y. | |
| v[x, y] = rate * p[x] / p[y] # The reverse rate detailed balance forces. | |
| return v - torch.diag(v.sum(dim=0)) # Each column now sums to zero. | |
| # %% 03. A zero-flux velocity on three states | |
| # | |
| # On the states A, C, G with p_t = (0.2, 0.3, 0.5) we can pick the rate 3 from A to C and the rate 1 | |
| # from A to G, and detailed balance returns 2 and 0.4. If we add this to a velocity that sends every | |
| # state to G at rate 1, the total rate of leaving A goes from 1 to 5 while p_dot stays put. | |
| def three_state_example(): | |
| """Section 5.13.2, returning the two velocities, the path and the two rates of change.""" | |
| p = torch.tensor([0.2, 0.3, 0.5], dtype=EXACT) # [3] over A, C, G. | |
| v = detailed_balance_velocity(p, {(0, 1): 3.0, (0, 2): 1.0}) # A to C at 3, A to G at 1. | |
| u = torch.zeros(3, 3, dtype=EXACT) # The velocity that does move the path. | |
| for x in range(2): | |
| u[2, x], u[x, x] = 1.0, -1.0 # A and C jump to G at rate 1; G never leaves. | |
| return p, u, v, net_flux(u, p), net_flux(u + v, p) # The last two agree. | |
| # %% 04. The corrector for the mask source | |
| # | |
| # Take the linear schedule, the weights alpha_t = 1 + eta t and beta_t = eta t, and the mask source. | |
| # The backward velocity is then the point mass on the mask, and the combination collapses to two | |
| # brackets. | |
| # A masked position unmasks at total rate (1 + eta t)/(1 - t), split over tokens by the denoiser, and | |
| # a visible position remasks at rate eta. | |
| def corrector_velocity(p1, x_i, t, eta): | |
| """bar_u^i_t(., x) over the five symbols A, C, G, T, m. p1: [4] -> [5].""" | |
| current = torch.zeros(DNA_SIZE + 1, dtype=EXACT) | |
| current[x_i] = 1.0 # delta on the token the position holds, possibly the mask. | |
| denoiser = torch.cat([p1.to(EXACT), torch.zeros(1, dtype=EXACT)]) # The data token is never m. | |
| to_mask = torch.zeros(DNA_SIZE + 1, dtype=EXACT) | |
| to_mask[MASK] = 1.0 # delta_m, the destination of a remasking. | |
| forward = (1.0 + eta * t) / (1.0 - t) * (denoiser - current) # The first bracket. | |
| return forward + eta * (to_mask - current) # The second bracket, the circulation back to m. | |
| def admissible_step(t, eta): | |
| """The largest Euler step with a redraw probability at most one, h <= (1 - t)/(1 + eta t).""" | |
| return (1.0 - t) / (1.0 + eta * t) # 0.1, 0.0357 and 0.01 at t = 0.9 for eta = 0, 2, 10. | |
| # %% 05. Counting the jumps | |
| # | |
| # Write M(t) for the probability that the position is masked. The two flows cancel, so M_dot(t) = -1 | |
| # for every eta and M(t) = 1 - t whatever the corrector strength. If we integrate the unmasking rate | |
| # against M and the remasking rate against 1 - M, we get 1 + eta/2 unmaskings and eta/2 remaskings, | |
| # for 1 + eta jumps per position. | |
| def masked_fraction_derivative(t, eta, masked=None): | |
| """M_dot(t) = -M (1 + eta t)/(1 - t) + (1 - M) eta, equal to -1 at M = 1 - t.""" | |
| masked = 1.0 - t if masked is None else masked # The solution we expect. | |
| return -masked * (1.0 + eta * t) / (1.0 - t) + (1.0 - masked) * eta | |
| def simulate_jumps(p1, eta, positions, steps, generator=None): | |
| """Run ``positions`` independent positions from the mask and count their jumps. | |
| Returns the fraction still masked at t = 0.5 and the mean number of jumps per position. | |
| """ | |
| state = torch.full((positions,), MASK, dtype=torch.long) # Every position masked at t = 0. | |
| jumps = torch.zeros(positions, dtype=torch.int32) # One counter per position. | |
| h, halfway = 1.0 / steps, None # The increment, and the masked fraction recorded at t = 0.5. | |
| for k in range(steps): | |
| t = k * h | |
| masked = state == MASK # [P] which positions are still at the mask symbol. | |
| if halfway is None and t >= 0.5: | |
| halfway = float(masked.to(torch.get_default_dtype()).mean()) # M(0.5), put at 1 - t by the chapter. | |
| up = min(1.0, h * (1.0 + eta * t) / max(1.0 - t, 1e-12)) # Unmasking probability this step. | |
| down = h * eta # Remasking probability this step. | |
| coin = torch.rand(positions, generator=generator) | |
| unmask = masked & (coin < up) # Masked positions that reveal a token. | |
| remask = (~masked) & (coin < down) # Visible positions that return to the mask. | |
| revealed = int(unmask.sum()) # Draw a token only where one is needed. | |
| if revealed: | |
| tokens = torch.multinomial(p1, revealed, replacement=True, generator=generator) | |
| state = state.masked_scatter(unmask, tokens) # Split over tokens by the denoiser. | |
| state = state.masked_fill(remask, MASK) # The circulation back to the mask symbol. | |
| jumps += (unmask | remask).to(torch.int32) # Every reveal and remasking counts as a jump. | |
| return halfway, float(jumps.double().mean()) # Near 1 - t = 0.5, and near 1 + eta. | |
| # %% 06. The parallel-step error | |
| # | |
| # Let the data be uniform over AA and TT and the source be all masks. With both positions masked the | |
| # denoiser at each is (0.5, 0.5), and a step that reveals both at once draws them independently, so | |
| # half of those draws land outside the data. With eta > 0 a pair revealed jointly is remasked at rate | |
| # 2 eta and redrawn with the other position visible, where the denoiser is a point mass. | |
| def joint_reveal_rate(steps): | |
| """The probability that the two reveal times land in the same cell of a uniform grid, h.""" | |
| return 1.0 / steps # Sum over cells of h squared, or 1/64 at 64 steps. | |
| def pair_survival(eta, tau=0.5): | |
| """exp(-2 eta (1 - tau)), the chance a pair revealed at tau is untouched at t = 1.""" | |
| return math.exp(-2.0 * eta * (1.0 - tau)) # 0.135 at eta = 2 and 4.54e-5 at eta = 10. | |
| def simulate_survival(eta, samples, steps, tau=0.5, generator=None): | |
| """The fraction of pairs revealed at ``tau`` that neither position remasks before t = 1. | |
| Each of the two positions returns to the mask at rate eta, so the first remasking of either | |
| happens at rate 2 eta, and a pair that is remasked is redrawn with the other position visible. | |
| """ | |
| remaining = max(1, int(steps * (1.0 - tau))) # Grid cells left between tau and t = 1. | |
| h = (1.0 - tau) / remaining # The increment, matching the grid the sampler runs on. | |
| untouched = torch.ones(samples, dtype=torch.bool) # Pairs no remasking has reached. | |
| for _ in range(remaining): | |
| coin = torch.rand(samples, 2, generator=generator) # One clock per position. | |
| untouched &= ~(coin < h * eta).any(dim=1) # The survival ends as soon as either position remasks. | |
| return float(untouched.to(torch.get_default_dtype()).mean()) # Approaches exp(-2 eta (1 - tau)). | |
| def simulate_pair(eta, samples, steps, generator=None): | |
| """The AA-or-TT two-position problem under the corrector. Returns the fraction outside the data.""" | |
| state = torch.full((samples, 2), MASK, dtype=torch.long) # Both positions masked at t = 0. | |
| h = 1.0 / steps | |
| for k in range(steps): | |
| t, last = k * h, k == steps - 1 # The last step reveals whatever is left. | |
| masked = state == MASK # [S, 2] read before either update, so the two are simultaneous. | |
| other = state.flip(1) # The token at the opposite position, read by the denoiser. | |
| guess = torch.randint(0, 2, state.shape, generator=generator) # The (0.5, 0.5) denoiser. | |
| drawn = torch.where(other != MASK, other, guess) # A point mass once the partner is visible. | |
| up = 1.0 if last else min(1.0, h * (1.0 + eta * t) / max(1.0 - t, 1e-12)) | |
| down = 0.0 if last else h * eta # No remasking on the step that ends the run. | |
| reveal = masked & (torch.rand(state.shape, generator=generator) < up) | |
| remask = (~masked) & (torch.rand(state.shape, generator=generator) < down) | |
| state = torch.where(reveal, drawn, state) # Unmask, in parallel at both positions. | |
| state = torch.where(remask, torch.full_like(state, MASK), state) # The circulation back to m. | |
| return float((state[:, 0] != state[:, 1]).double().mean()) # Outside {AA, TT}. | |
| # %% 07. The report | |
| def main(): | |
| parser = common_parser(__doc__.split("\n")[0]) | |
| parser.add_argument("--eta", type=float, default=2.0, help="corrector strength in the step table") | |
| parser.add_argument("--positions", type=int, default=40_000, help="positions in the simulation") | |
| parser.add_argument("--pairs", type=int, default=20_000, help="sequences in the pair simulation") | |
| args = parser.parse_args() | |
| set_seed(args.seed) | |
| p, u, v, with_u, with_both = three_state_example() | |
| report = { | |
| "zero_flux": [round(float(f), 6) for f in net_flux(v, p)], # Zero at A, C and G. | |
| "p_dot_u": [round(float(f), 4) for f in with_u], # (-0.2, -0.3, 0.5). | |
| "p_dot_u_plus_v": [round(float(f), 4) for f in with_both], # The same three numbers. | |
| "rate_out_of_A_u": round(float(-u[0, 0]), 4), # 1 under the velocity alone. | |
| "rate_out_of_A_both": round(float(-(u + v)[0, 0]), 4), # 5 once the corrector is added. | |
| } | |
| # Section 5.14.3: one masked position at t = 0.4 with the chapter's denoiser and h = 0.01. | |
| p1 = torch.tensor([0.1, 0.6, 0.2, 0.1]) # [4] over A, C, G, T. | |
| bar_u = corrector_velocity(p1, MASK, t=0.4, eta=args.eta) # [5] rates out of the mask. | |
| seen = torch.zeros(DNA_SIZE) | |
| seen[1] = 1.0 # At a visible position the true denoiser is the point mass on the visible token. | |
| report["unmask_rate"] = round(float(-bar_u[MASK]), 4) # (1 + eta t)/(1 - t); 3.0 at eta = 2. | |
| report["remask_rate"] = round(float(corrector_velocity(seen, 1, 0.4, args.eta)[MASK]), 4) | |
| report["step_probabilities"] = [round(float(0.01 * r), 5) for r in bar_u[:DNA_SIZE]] | |
| report["stay_masked"] = round(float(1.0 + 0.01 * bar_u[MASK]), 5) # 0.97 at eta = 2. | |
| report["remask_probability"] = round(0.01 * args.eta, 4) # eta h, or 0.02 at eta = 2. | |
| report["masked_derivative"] = round(masked_fraction_derivative(0.4, args.eta), 6) # -1. | |
| report["admissible_step_at_0.9"] = round(admissible_step(0.9, args.eta), 4) # 0.0357 at eta = 2. | |
| # The long simulation, with the letter frequency of the synthetic DNA as the denoiser. | |
| splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed) | |
| counts = torch.bincount(splits["train_tokens"].flatten(), minlength=DNA_SIZE).double() | |
| frequency = counts / counts.sum() # [4] the GC-rich background the lecture shares. | |
| for eta in (0.0, 2.0, 10.0): | |
| half, mean = simulate_jumps(frequency, eta, args.positions, args.steps) | |
| report[f"masked_at_0.5_eta_{eta:g}"] = round(half, 4) # 1 - t = 0.5 at every eta. | |
| report[f"jumps_eta_{eta:g}"] = round(mean, 3) # 1 + eta. | |
| # Section 5.14.4: the two positions that have to agree. | |
| report["joint_reveal_at_64"] = round(joint_reveal_rate(64), 4) # 0.0156. | |
| report["error_at_64_predicted"] = round(0.5 * joint_reveal_rate(64), 4) # Half of those are wrong. | |
| report["error_at_64_measured"] = f"{simulate_pair(0.0, args.pairs, 64):.5f}" # Near 0.0078. | |
| report["error_at_grid_measured"] = f"{simulate_pair(0.0, args.pairs, args.steps):.5f}" | |
| for eta in (0.0, 2.0, 10.0): | |
| predicted = pair_survival(eta) # exp(-2 eta (1 - tau)) at the midpoint tau = 0.5. | |
| report[f"survival_eta_{eta:g}"] = f"{predicted:.3e}" | |
| if predicted * args.pairs >= 20: # Below that, the simulation has nothing to resolve. | |
| measured = simulate_survival(eta, args.pairs, args.steps) # The same number, simulated. | |
| report[f"survival_eta_{eta:g}_measured"] = f"{measured:.3e}" | |
| print_report("correctors", report) | |
| save_run(args.out, config=vars(args), report=report) | |
| if __name__ == "__main__": | |
| main() | |