File size: 15,912 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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
"""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()