Download lecture_5/edit_flows.py from ChatterjeeLab/CIS6270: direct link, hf CLI and curl.
- Browser
- Download file 15.9 kB
-
https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_5/edit_flows.py
- Command line
-
hf download hf://ChatterjeeLab/CIS6270/lecture_5/edit_flows.py
-
curl -L -o edit_flows.py https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_5/edit_flows.py
15.9 kB
| """Edit Flows: paths whose sequences change length. | |
| Every path so far keeps a single length, because a substitution changes a token and never adds or | |
| removes one. If we enlarge the set of moves to insertions, deletions and substitutions, that | |
| constraint goes away. An alignment pads the two ends with a blank until they have the same number of | |
| columns, and the mixture construction then applies column by column. The alignment is an auxiliary | |
| variable we marginalize exactly as we marginalized the data sequence. The loss scores rates in place | |
| of a distribution over tokens, and its minimizer is the rate at which an edit makes progress. | |
| 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, Section 5.17. Runs on the synthetic DNA of ``cis6270.data``, trimmed to varying lengths. | |
| Prints the alignment of the chapter, the posterior over two alignments, the averaged edit rates, the | |
| loss of one example, one sampler step, and the length statistics of a run on DNA. | |
| python lecture_5/edit_flows.py # 600 sampler steps on 256 DNA pairs | |
| python lecture_5/edit_flows.py --steps 20 --quiet # a coarse grid, a visibly coarser length | |
| """ | |
| # %% 01. Imports and configuration | |
| import math | |
| import torch | |
| from cis6270.data import DNA, DNA_SIZE, dna_splits, encode_dna | |
| from cis6270.runner import common_parser, print_report, save_run, set_seed | |
| BLANK = DNA_SIZE # Index 4 is the blank symbol epsilon that padding writes into a column. | |
| # %% 02. Three edits and the rate split | |
| # | |
| # Each position of the current sequence has three edits, and each rate splits into an intensity and | |
| # a distribution over tokens. A deletion writes no token, so the network emits three intensities and | |
| # two token distributions per position, or 3 + 2K numbers in place of the K of the denoiser. | |
| def edit_rates(intensities, tokens=DNA_SIZE): | |
| """Count the numbers a network emits per position, and the total rate of leaving the sequence.""" | |
| total = sum(sum(kind) for kind in intensities.values()) # -u_t(x, x), the total exit rate. | |
| return 3 + 2 * tokens, total # Three intensities and two token distributions per position. | |
| # %% 03. Alignments and the column-wise path | |
| # | |
| # An alignment puts the positions of the two sequences in correspondence by padding both with the | |
| # blank. A blank in the target column is a deletion, a blank in the source column an insertion, and | |
| # two different tokens a substitution. On the aligned pair each column follows the mixture path | |
| # independently, and once we remove the blanks from z_t we have the sequence x_t the model reads. | |
| def align(x0, x1): | |
| """A minimal alignment of two token lists, as the padded pair (z_0, z_1) of equal length.""" | |
| n, m = len(x0), len(x1) | |
| cost = [[0] * (m + 1) for _ in range(n + 1)] # Edit distance with unit costs. | |
| for i in range(n + 1): | |
| cost[i][0] = i # Deleting the first i tokens of the source. | |
| for j in range(m + 1): | |
| cost[0][j] = j # Inserting the first j tokens of the target. | |
| for i in range(1, n + 1): | |
| for j in range(1, m + 1): | |
| cost[i][j] = min(cost[i - 1][j] + 1, cost[i][j - 1] + 1, | |
| cost[i - 1][j - 1] + int(x0[i - 1] != x1[j - 1])) | |
| z0, z1, i, j = [], [], n, m | |
| while i or j: # One backtrace through the table, taking the diagonal where it is minimal. | |
| if i and j and cost[i][j] == cost[i - 1][j - 1] + int(x0[i - 1] != x1[j - 1]): | |
| z0.append(x0[i - 1]), z1.append(x1[j - 1]), (i := i - 1), (j := j - 1) | |
| elif i and cost[i][j] == cost[i - 1][j] + 1: | |
| z0.append(x0[i - 1]), z1.append(BLANK), (i := i - 1) # A deletion column. | |
| else: | |
| z0.append(BLANK), z1.append(x1[j - 1]), (j := j - 1) # An insertion column. | |
| return z0[::-1], z1[::-1] # [C] and [C] over the same number of columns. | |
| def column_kinds(z0, z1): | |
| """Name the edit each column owes: ``del``, ``ins``, ``sub``, or ``none`` when the two agree.""" | |
| kinds = [] | |
| for a, b in zip(z0, z1): | |
| kinds.append("none" if a == b else "del" if b == BLANK else "ins" if a == BLANK else "sub") | |
| return kinds # [C] one label per column. | |
| def draw_state(z0, z1, finished): | |
| """z_t from a choice of which columns already hold their target, with the blanks removed.""" | |
| held = [b if done else a for a, b, done in zip(z0, z1, finished)] # [C] the aligned state. | |
| return [token for token in held if token != BLANK] # [|x_t|] the sequence the model reads. | |
| def expected_length(x0, x1, kappa_t): | |
| """E[|x_t|] = (1 - kappa_t)|x_0| + kappa_t|x_1|, the interpolation one position up.""" | |
| return (1.0 - kappa_t) * len(x0) + kappa_t * len(x1) # Linear from |x_0| to |x_1|. | |
| # %% 04. Averaging over the alignments we cannot see | |
| # | |
| # Many alignments produce the same pair, and x_t does not record which one the path is on. Bayes' | |
| # rule over the alignments weights each by the probability that its column draw produces the observed | |
| # x_t, and the rates average against those weights. | |
| def alignment_posterior(alignments, observed, kappa_t): | |
| """p_t(A | x_t) over a list of aligned pairs, by enumerating the column draws of each.""" | |
| weights = [] | |
| for z0, z1 in alignments: | |
| owed = [i for i, kind in enumerate(column_kinds(z0, z1)) if kind != "none"] # Unfinished. | |
| total = 0.0 | |
| for mask in range(1 << len(owed)): # Every subset of the owed columns already finished. | |
| finished = [True] * len(z0) | |
| probability = 1.0 | |
| for bit, column in enumerate(owed): | |
| done = bool(mask >> bit & 1) | |
| finished[column] = done | |
| probability *= kappa_t if done else 1.0 - kappa_t # The column-wise mixture path. | |
| if draw_state(z0, z1, finished) == observed: | |
| total += probability # This draw is consistent with what we see. | |
| weights.append(total) | |
| norm = sum(weights) | |
| return [w / norm for w in weights] # One posterior weight per alignment. | |
| def averaged_rates(alignments, observed, kappa_t, kappa_dot=1.0): | |
| """The marginal rate of each edit that finishes a column, averaged over the alignments. | |
| ``observed`` is the draw in which every column that owes an edit still holds its source symbol. | |
| The worked example of Section 5.17.2 reads the rates at that state. | |
| """ | |
| rate = kappa_dot / (1.0 - kappa_t) # The weight on every unfinished column. | |
| posterior, out = alignment_posterior(alignments, observed, kappa_t), {} | |
| for (z0, z1), weight in zip(alignments, posterior): | |
| held = [b if kind == "none" else a # A column owing nothing is finished at every time. | |
| for a, b, kind in zip(z0, z1, column_kinds(z0, z1))] | |
| for i, (a, b, kind) in enumerate(zip(z0, z1, column_kinds(z0, z1))): | |
| seen = sum(1 for token in held[:i] if token != BLANK) # Position inside x_t. | |
| if kind == "sub": | |
| name = f"sub({seen + 1},{DNA[b]})" # Replace the token at that position. | |
| elif kind == "ins": | |
| name = f"ins({seen},{DNA[b]})" # Place a token to the right of that position. | |
| elif kind == "del": | |
| name = f"del({seen + 1})" # Remove the token at that position. | |
| else: | |
| continue # Nothing is owed here. | |
| out[name] = out.get(name, 0.0) + weight * rate | |
| return posterior, out | |
| # %% 05. The training loss | |
| # | |
| # The first term is the sum of the model's intensities and pushes all of them down. The second is a | |
| # weighted cross-entropy on the edits that move x_t toward x_1 and pushes those up. If we collect | |
| # the terms belonging to one edit we get lambda - w log lambda, with stationary point lambda = w. | |
| def edit_loss(total_intensity, owed, kappa_t, kappa_dot=1.0): | |
| """L = sum of all intensities - sum over unfinished columns of the weight times log lambda.""" | |
| weight = kappa_dot / (1.0 - kappa_t) # kappa_dot/(1 - kappa), or 2 at t = 0.5. | |
| return total_intensity - sum(weight * math.log(lam) for lam in owed) # The model's own rates. | |
| def loss_minimizer(weight, grid): | |
| """The intensity minimizing lambda - w log lambda on a grid, put at w by the derivative.""" | |
| values = [lam - weight * math.log(lam) for lam in grid] # The contribution of one edit. | |
| return grid[min(range(len(grid)), key=values.__getitem__)] # Near w, with w/lambda^2 > 0. | |
| # %% 06. One step of the sampler | |
| # | |
| # Each position inserts with probability h lambda_ins, and deletes or substitutes with probability | |
| # h(lambda_del + lambda_sub), the two sharing that draw in proportion to their intensities. We apply | |
| # every chosen edit at once, so the step has the same second-order error as the parallel step on a | |
| # fixed length. | |
| def step_probabilities(intensities, h): | |
| """The per-position insertion and delete-or-substitute probabilities of one Euler step.""" | |
| insert = [h * lam for lam in intensities["ins"]] # One coin per position. | |
| change = [h * (d + s) for d, s in zip(intensities["del"], intensities["sub"])] # A second coin. | |
| return insert, change | |
| def untouched_probability(intensities, h): | |
| """The chance the step applies no edit anywhere, and the first-order estimate beside it.""" | |
| insert, change = step_probabilities(intensities, h) | |
| exact = math.prod(1.0 - p for p in insert + change) # Every coin coming up tails. | |
| _, total = edit_rates(intensities) | |
| return exact, 1.0 - h * total # The gap is the second-order term from two edits at once. | |
| def expected_length_change(intensities, h): | |
| """h times the insertion intensities minus the deletion intensities, in tokens.""" | |
| return h * (sum(intensities["ins"]) - sum(intensities["del"])) # Nothing fixes the length. | |
| # %% 07. Edits on the synthetic DNA | |
| # | |
| # We align each pair of sequences once, and every column that owes an edit finishes at rate | |
| # kappa_dot/(1 - kappa). The sequence the model reads is the aligned state with its blanks removed. | |
| # The realized length wanders while its mean follows the interpolation. | |
| def simulate_columns(pairs, steps, generator=None): | |
| """Run the column-wise path on a list of aligned pairs. Returns the lengths and the end states.""" | |
| width = max(len(z0) for z0, _ in pairs) # Pad every alignment to the widest one. | |
| src = torch.full((len(pairs), width), BLANK) | |
| tgt = torch.full((len(pairs), width), BLANK) | |
| valid = torch.zeros(len(pairs), width, dtype=torch.bool) | |
| for row, (z0, z1) in enumerate(pairs): | |
| src[row, :len(z0)] = torch.tensor(z0) | |
| tgt[row, :len(z1)] = torch.tensor(z1) | |
| valid[row, :len(z0)] = True # Columns beyond the alignment take no part. | |
| done = (src == tgt) & valid # A column whose two symbols agree owes no edit. | |
| h, halfway = 1.0 / steps, None | |
| for k in range(steps): | |
| t = k * h | |
| if halfway is None and t >= 0.5: | |
| held = torch.where(done, tgt, src) # [P, C] the aligned state at this time. | |
| halfway = float(((held != BLANK) & valid).sum(dim=1).double().mean()) | |
| lam = 1.0 if k == steps - 1 else min(1.0, h / max(1.0 - t, 1e-12)) # lambda_t per column. | |
| coin = torch.rand(src.shape, generator=generator) < lam | |
| done = done | (coin & valid) # An unfinished column takes its target symbol. | |
| finished = float((done | ~valid).all(dim=1).double().mean()) # Fraction of pairs that arrived. | |
| return halfway, finished | |
| # %% 08. The report | |
| def main(): | |
| parser = common_parser(__doc__.split("\n")[0]) | |
| parser.add_argument("--pairs", type=int, default=256, help="DNA pairs aligned and run") | |
| args = parser.parse_args() | |
| set_seed(args.seed) | |
| # Section 5.17.1: the chapter's alignment of ACGT with AGAC over five columns. | |
| x0, x1 = encode_dna(["ACGT"])[0].tolist(), encode_dna(["AGAC"])[0].tolist() | |
| z0 = x0 + [BLANK] # (A, C, G, T, eps). | |
| z1 = [x1[0], BLANK] + x1[1:] # (A, eps, G, A, C). | |
| kinds = column_kinds(z0, z1) | |
| draw = [True, False, True, True, True] # Column 2 behind, columns 4 and 5 already at the target. | |
| report = { | |
| "column_kinds": kinds, # none, del, none, sub, ins. | |
| "draw_at_kappa_0.5": "".join(DNA[token] for token in draw_state(z0, z1, draw)), # ACGAC. | |
| "draw_probability": round(0.5 ** sum(kind != "none" for kind in kinds), 4), # 0.125. | |
| "expected_length": round(expected_length(x0, x1, 0.5), 4), # 4, both ends having length 4. | |
| "numbers_per_position": edit_rates({"ins": [0.0], "del": [0.0], "sub": [0.0]})[0], # 11. | |
| } | |
| # Section 5.17.2: the pair AC and AAC, with two alignments on three columns. | |
| short, long = encode_dna(["AC"])[0].tolist(), encode_dna(["AAC"])[0].tolist() | |
| first = ([short[0], short[1], BLANK], long) # Substitute C with A, then insert C. | |
| second = ([short[0], BLANK, short[1]], long) # Insert A, and leave the last column alone. | |
| observed = short # The noisy sequence x_t = AC. | |
| for kappa_t in (0.25, 0.5, 0.75): | |
| weights = alignment_posterior([first, second], observed, kappa_t) | |
| report[f"posterior_A1_kappa_{kappa_t}"] = round(weights[0], 4) # (1-kappa)/(2-kappa). | |
| posterior, rates = averaged_rates([first, second], observed, 0.5) | |
| for name in sorted(rates): | |
| report[f"rate_{name}"] = round(rates[name], 3) # 0.667, 1.333 and 0.667. | |
| report["total_target_rate"] = round(sum(rates.values()), 3) # 2.667. | |
| # Section 5.17.3: one unfinished column, a model intensity of 0.8, and 3.0 over all edits. | |
| report["example_loss"] = round(edit_loss(3.0, [0.8], kappa_t=0.5), 4) # 3.446. | |
| grid = [0.01 * i for i in range(1, 801)] # Intensities between 0.01 and 8. | |
| report["loss_minimizer"] = round(loss_minimizer(2.0, grid), 2) # 2, the weight the edit receives. | |
| # Section 5.17.4: one whole step on ACG with the chapter's nine intensities. | |
| intensities = {"ins": [0.2, 0.3, 0.4], "del": [0.1, 0.9, 0.2], "sub": [0.4, 0.3, 0.5]} | |
| exact, first_order = untouched_probability(intensities, 0.05) | |
| insert, change = step_probabilities(intensities, 0.05) | |
| report["insert_probabilities"] = [round(p, 3) for p in insert] # 0.010, 0.015, 0.020. | |
| report["change_probabilities"] = [round(p, 3) for p in change] # 0.025, 0.060, 0.035. | |
| report["deletion_given_change"] = round(0.9 / (0.9 + 0.3), 4) # 0.75, three times in four. | |
| report["untouched"] = round(exact, 4) # 0.8452. | |
| report["untouched_first_order"] = round(first_order, 4) # 0.835. | |
| report["second_order_gap"] = round(exact - first_order, 4) # 0.0102. | |
| report["length_change"] = round(expected_length_change(intensities, 0.05), 4) # -0.015. | |
| # The same path on the shared DNA, trimmed so that the two ends have different lengths. | |
| splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed) | |
| tokens = splits["train_tokens"] | |
| cuts = torch.randint(args.length - 4, args.length + 1, (2 * args.pairs,)) # Varying lengths. | |
| rows = [tokens[i % len(tokens), :cuts[i]].tolist() for i in range(2 * args.pairs)] | |
| pairs = [align(rows[2 * i], rows[2 * i + 1]) for i in range(args.pairs)] | |
| predicted = sum(expected_length(rows[2 * i], rows[2 * i + 1], 0.5) for i in range(args.pairs)) | |
| measured, arrived = simulate_columns(pairs, args.steps) | |
| report["dna_columns_mean"] = round(sum(len(z0) for z0, _ in pairs) / len(pairs), 3) | |
| report["dna_length_at_0.5"] = round(measured, 3) # Measured over the run. | |
| report["dna_length_predicted"] = round(predicted / args.pairs, 3) # The interpolation. | |
| report["dna_pairs_arrived"] = round(arrived, 4) # Every column finishes by t = 1. | |
| report["dna_example"] = " -> ".join("".join(DNA[t] for t in row) for row in rows[:2]) | |
| print_report("edit flows", report) | |
| save_run(args.out, config=vars(args), report=report) | |
| if __name__ == "__main__": | |
| main() | |