File size: 14,437 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
"""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()