pranamanam commited on
Commit
3124c47
·
verified ·
1 Parent(s): 14eaced

Delete lecture_5

Browse files
lecture_5/README.md DELETED
@@ -1,97 +0,0 @@
1
- # Lecture 5: Discrete Flow Matching (Chapter 5)
2
-
3
- Chapter 4 fixed a corruption process and solved for its reverse. This lecture reverses that order.
4
- We pick the probability path first, write down a velocity that generates it, and read the sampler
5
- off the velocity. On tokens the velocity is a rate matrix read from the destination's side, the
6
- master equation is the condition under which it generates a path, and the mixture path
7
- `(1 - kappa_t) delta_{x_0} + kappa_t delta_{x_1}` at each position has a conditional velocity that a
8
- posterior average turns into the schedule factor times a denoiser. Cross-entropy on the data token
9
- recovers that denoiser. Everything after that is a choice made inside the same construction: a
10
- schedule, a corrector, a kinetic-optimal path, edits that change the length, a relaxation of the
11
- token into a point of the simplex or of a sphere, a guidance tilt, or a different coupling.
12
-
13
- Two conventions hold in every file and are stated at the top of each. Time runs from the source at
14
- `t = 0` to the data at `t = 1`, so `kappa_t` here equals the survival probability `alpha_{1-t}` of
15
- Chapter 4. The probability velocity `u_t(y, x)` is the same number Chapter 4 writes as the rate
16
- `R_t(x, y)`, with the two arguments exchanged.
17
-
18
- ## The files
19
-
20
- Everything runs on the synthetic DNA of `cis6270.data`: 512 sequences of length 16 over A, C, G, T,
21
- with a GC-rich background and the motif ACGT planted in three quarters of them at one of three
22
- positions. Those sequences have GC fraction 0.577, motif fraction 0.777 and token entropy 1.374 of
23
- `log 4 = 1.386`, which are the numbers a trained sampler is measured against.
24
-
25
- | File | Command | What it does |
26
- | --- | --- | --- |
27
- | `dfm.py` | `python lecture_5/dfm.py` | The coupling, the mixture path, the conditional velocity, the posterior average, the cross-entropy loss and the two-part Euler rule. Trains a denoiser and generates DNA from either source, `--source uniform` or `--source mask`. |
28
- | `velocity.py` | `python lecture_5/velocity.py` | The mathematics with nothing learned: a jump rate written as a velocity, the two rules, the master equation, the worked DNA velocity, survival and hazard, and the continuous and discrete velocities side by side. |
29
- | `corrector.py` | `python lecture_5/corrector.py` | Velocities with zero net flux, the three-state example, the corrector for the mask source, the jump counts at three corrector strengths, and the parallel-step error. |
30
- | `kinetic_optimal.py` | `python lecture_5/kinetic_optimal.py` | The flux and its energy, the velocity of least energy, the kinetic optimality of the mixture path, the transition distance on DNA, the worked metric path, and the energy of the linear and geodesic schedules. |
31
- | `edit_flows.py` | `python lecture_5/edit_flows.py` | Insertions, deletions and substitutions; alignments and the column-wise path; the posterior over two alignments; the rate loss; one sampler step; and the length statistics of a run on DNA pairs of different lengths. |
32
- | `dirichlet.py` | `python lecture_5/dirichlet.py` | The Dirichlet family as a conditional path, the speed from the flux across a cut, the two-letter closed form, the worked speeds on a DNA base, and the posterior average. Trains a denoiser on the simplex and integrates to `t = 8`. |
33
- | `fisher.py` | `python lecture_5/fisher.py` | The Fisher information as a metric, the square-root map to the sphere, geodesics, the tangent projection and the regression loss. Trains a velocity field and samples with exponential-map steps. |
34
- | `gumbel.py` | `python lecture_5/gumbel.py` | The Gumbel-max trick, the cooling temperature, the annealed path, the replicator equation, the inference-time velocity, and training against the marginal velocity. |
35
- | `guidance.py` | `python lecture_5/guidance.py` | Guidance as a tilt of the rates, the h-transform, the reachable set, the guidance strength, the classifier-free form, the Taylor expansion of the log ratio, and the guided sampler. Steers toward the GC label with `--gamma`. |
36
- | `multi_objective.py` | `python lecture_5/multi_objective.py` | The Pareto front, MOG-DFM's rank, direction and cone with the controller that widens it, and pCoMole's worst-objective utility with a rollout of the base flow on DNA. |
37
- | `rectify.py` | `python lecture_5/rectify.py` | Total correlation on two bases, ReDi's recoupling rounds run on the exact sampler, and AReUReDi's Tchebycheff tilt with its Metropolis acceptance step. |
38
-
39
- Every file takes the shared flags of `cis6270.runner`. `--steps 20 --quiet` runs each one in a few
40
- seconds, and `--steps` controls the training steps in the five files that train, the grid in the
41
- five that integrate, and the number of rollouts in `multi_objective.py`.
42
-
43
- ## The numbers a default run produces
44
-
45
- Seed 0, one core. The five files that train take under half a minute each, and the other six take
46
- one to seven seconds. The worked numbers are exact and repeat on any machine; the trained numbers
47
- move a little with the hardware's floating point.
48
-
49
- | File | Headline numbers |
50
- | --- | --- |
51
- | `dfm.py` | loss 0.876 nats, GC fraction 0.578, motif fraction 0.672, token entropy 1.374 of `log 4 = 1.386` |
52
- | `velocity.py` | `p_dot = (-0.1, -0.2, 0.7, -0.4)`, Euler step `(0.09, 0.18, 0.37, 0.36)`, hazard 2 at `t = 0.5` and 10 at `t = 0.9`, Euler error 0.0002 against the closed form |
53
- | `corrector.py` | masked fraction 0.500 at `t = 0.5` for every `eta`, jumps per position 1.000, 2.991 and 10.934 at `eta = 0, 2, 10`, pair survival 0.133 measured against the predicted 0.135 at `eta = 2` |
54
- | `kinetic_optimal.py` | rate out of C 5.780, `p_dot = (-0.3997, -0.4774, 1.3546, -0.4774)` from the velocity and from the path, energy 27.63 and 41.45 for the linear schedule, `pi^2 = 9.8696` for the geodesic one |
55
- | `edit_flows.py` | averaged edit rates 0.667, 1.333 and 0.667 for a total of 2.667, example loss 3.446, untouched 0.8452 against the first-order 0.8350, DNA length 13.945 at `t = 0.5` against the predicted 13.941 |
56
- | `dirichlet.py` | speeds 0.232, 0.199, 0.159, 0.104 at `t = 1`, continuity residual `2.5e-08`, loss 1.027 nats, GC fraction 0.532, motif fraction 0.547 |
57
- | `fisher.py` | Fisher-Rao distance 1.485 against the Euclidean 0.141 and 0.013 on the two moves, arc 0.3713 from the midpoint, loss 0.278, GC fraction 0.576, motif fraction 0.203 |
58
- | `gumbel.py` | path `(0.0528, 0.0214, 0.8781, 0.0477)` at `t = 1`, replicator velocity `(-0.136, -0.217, 0.500, -0.147)`, loss 0.672 nats, GC fraction 0.572, motif fraction 0.516 |
59
- | `guidance.py` | guided destination `(0.029, 0.203, 0.478, 0.290)`, exit rate 2.50 at the exact `h`, GC fraction 0.531 unguided, 0.657 at `gamma = 2` and 0.698 at `gamma = 5` |
60
- | `multi_objective.py` | combined score `(-1.511, 1.541, 2.297, -2.327)`, cone angles 47.7, 37.9 and 107.7 degrees, selection `(0.371, 0.629, 0)` after the chapter's rollouts, and on DNA a base selection of `(0.417, 0.583)` that the rollout value turns into `(0.962, 0.038)` |
61
- | `rectify.py` | total correlation 1.386, 1.101, 0.843, 0.696 and 0.616 over rounds 0 to 4 against the limit 0.520; one-step success 25.0%, 40.3%, 52.5%, 58.0% and 60.4% against the limit 62.5% |
62
-
63
- Four thousand training steps, about two minutes a file, buy a sampler that reaches the planted
64
- motif more often: `dfm.py` goes from 0.672 to 0.734 against the data's 0.777, and `fisher.py` from
65
- 0.203 to 0.453. Nothing else moves much.
66
-
67
- `tests/test_lecture_5.py` asserts the chapter's worked numbers against these files, including the
68
- posterior 0.625 and 0.375 at a masked position, the entropy 0.6616 that bounds the loss there, the
69
- velocity `(0.2, 1.2, -1.6, 0.2)` out of G, the metric-path rates 0.898 and 4.882 out of C, the
70
- Dirichlet speeds, the Fisher-Rao arc, the Gumbel-softmax path at four times, the guided destination
71
- distribution, the Pareto front, and the ReDi rounds.
72
-
73
- ## What is simplified
74
-
75
- - The corrector simulation uses 40,000 positions where the chapter uses 200,000. Pass
76
- `--positions 200000` to reproduce the chapter's figures exactly.
77
- - The parallel-step section measures the survival probability of a jointly revealed pair, which is
78
- the quantity the chapter computes in closed form. At a fine grid the end-to-end error of the
79
- corrector is not lower than at `eta = 0`, because the admissible step shrinks with `eta`.
80
- - `edit_flows.py` runs the column-wise path on one sampled alignment per pair, which the chapter's
81
- marginalization theorem permits, and trains nothing. The edit intensities of the sampler section
82
- are the chapter's own numbers, not a network's.
83
- - `dirichlet.py` evaluates the derivative of the regularized incomplete beta function by a central
84
- difference on `scipy.special.betainc`, in double precision whatever the ambient dtype, since the
85
- two values agree to six decimals. The continuity check differentiates the flux with a five-point
86
- stencil.
87
- - `rectify.py` runs ReDi with the exact marginal velocity of the current coupling on a
88
- two-position state space, so no network is trained. The AReUReDi section works one Metropolis
89
- iteration, not a full refinement chain.
90
- - `guidance.py` trains the denoiser and the property model under one optimizer, and the property is
91
- the GC label of `cis6270.data`, not a biological measurement.
92
- - `multi_objective.py` trains nothing. Its two DNA objectives are the GC fraction and the AT
93
- fraction of a finished sequence, its feasible set is the sequences carrying the planted motif,
94
- and the base flow a rollout continues is the mask-source sampler with the data's letter frequency
95
- as its denoiser.
96
- - The relaxed paths replace the token lookup of `cis6270.nets.SequenceDenoiser` with a linear map on
97
- the relaxed token, and leave the rest of the network untouched.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_5/corrector.py DELETED
@@ -1,251 +0,0 @@
1
- """Correctors: extra motion that leaves the probability path where it is.
2
-
3
- The master equation is linear in the velocity, so a second velocity with no net flux into any state
4
- can be added to the first without moving the marginals. Detailed balance is the easy way to arrange
5
- that, and the mixture path already contains such a term, the difference between the forward velocity
6
- and the same path run backward. Adding it with weight eta makes a position unmask more often and
7
- remask in between, which gives the sampler more chances to replace a token the rest of the sequence
8
- has ruled out.
9
-
10
- Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
11
- u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
12
-
13
- Chapter 5, Sections 5.13 and 5.14. The denoiser of the long simulation is the letter frequency of the
14
- synthetic DNA of ``cis6270.data``. Prints the zero-flux check, the per-step unmasking table, the jump
15
- counts at three corrector strengths, the parallel-step error at 64 steps, and the chance a jointly
16
- revealed pair survives to t = 1 untouched.
17
-
18
- python lecture_5/corrector.py # 600 Euler steps on 40,000 positions
19
- python lecture_5/corrector.py --eta 10 # one corrector strength in the step table
20
- python lecture_5/corrector.py --steps 20 --quiet # a coarse grid, visibly biased jump counts
21
- """
22
-
23
- # %% 01. Imports and configuration
24
- import math
25
-
26
- import torch
27
-
28
- from cis6270.data import DNA, DNA_SIZE, dna_splits
29
- from cis6270.runner import common_parser, print_report, save_run, set_seed
30
-
31
- EXACT = torch.double # The worked arithmetic keeps full precision whatever the default dtype.
32
- MASK = DNA_SIZE # Index 4 is the mask symbol m, outside the four-letter alphabet.
33
-
34
-
35
- # %% 02. Velocities that move samples without moving the path
36
- #
37
- # A velocity v_t whose net flux into every state is zero can be added to a velocity that generates
38
- # p_t, and the sum generates the same path. Detailed balance, equal flux in both directions along
39
- # every edge, is sufficient. Reversing every term turns the net flux into p_t(y) times a column of
40
- # rates, and those rates sum to zero.
41
-
42
-
43
- def net_flux(v, p):
44
- """sum_x v_t(y, x) p_t(x) at every state y. v: [K, K], p: [K] -> [K]."""
45
- return v @ p # Zero at every entry when v moves no probability.
46
-
47
-
48
- def detailed_balance_velocity(p, chosen):
49
- """Build v_t from chosen forward rates, with each reverse rate fixed by detailed balance.
50
-
51
- ``chosen`` maps an edge (x, y) to the rate v_t(y, x). The reverse rate is then
52
- v_t(x, y) = v_t(y, x) p_t(x) / p_t(y), which equalizes the flux along that edge.
53
- """
54
- v = torch.zeros(len(p), len(p), dtype=p.dtype) # [K, K], destination indexing the row.
55
- for (x, y), rate in chosen.items():
56
- v[y, x] = rate # The rate we pick, out of x toward y.
57
- v[x, y] = rate * p[x] / p[y] # The reverse rate detailed balance forces.
58
- return v - torch.diag(v.sum(dim=0)) # Each column now sums to zero.
59
-
60
-
61
- # %% 03. A zero-flux velocity on three states
62
- #
63
- # 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
64
- # from A to G, and detailed balance returns 2 and 0.4. Adding this to a velocity that sends every
65
- # state to G at rate 1 raises the total rate of leaving A from 1 to 5 while p_dot stays put.
66
-
67
-
68
- def three_state_example():
69
- """Section 5.13.2, returning the two velocities, the path and the two rates of change."""
70
- p = torch.tensor([0.2, 0.3, 0.5], dtype=EXACT) # [3] over A, C, G.
71
- v = detailed_balance_velocity(p, {(0, 1): 3.0, (0, 2): 1.0}) # A to C at 3, A to G at 1.
72
- u = torch.zeros(3, 3, dtype=EXACT) # The velocity that does move the path.
73
- for x in range(2):
74
- u[2, x], u[x, x] = 1.0, -1.0 # A and C jump to G at rate 1; G never leaves.
75
- return p, u, v, net_flux(u, p), net_flux(u + v, p) # The last two agree.
76
-
77
-
78
- # %% 04. The corrector for the mask source
79
- #
80
- # With the linear schedule, the weights alpha_t = 1 + eta t and beta_t = eta t, and the mask source,
81
- # the backward velocity is the point mass on the mask and the combination collapses to two brackets.
82
- # A masked position unmasks at total rate (1 + eta t)/(1 - t), split over tokens by the denoiser, and
83
- # a visible position remasks at rate eta.
84
-
85
-
86
- def corrector_velocity(p1, x_i, t, eta):
87
- """bar_u^i_t(., x) over the five symbols A, C, G, T, m. p1: [4] -> [5]."""
88
- current = torch.zeros(DNA_SIZE + 1, dtype=EXACT)
89
- current[x_i] = 1.0 # delta on the token the position holds, which may be the mask.
90
- denoiser = torch.cat([p1.to(EXACT), torch.zeros(1, dtype=EXACT)]) # The data token is never m.
91
- to_mask = torch.zeros(DNA_SIZE + 1, dtype=EXACT)
92
- to_mask[MASK] = 1.0 # delta_m, the destination of a remasking.
93
- forward = (1.0 + eta * t) / (1.0 - t) * (denoiser - current) # The first bracket.
94
- return forward + eta * (to_mask - current) # The second bracket, the circulation back to m.
95
-
96
-
97
- def admissible_step(t, eta):
98
- """The largest Euler step with a redraw probability at most one, h <= (1 - t)/(1 + eta t)."""
99
- return (1.0 - t) / (1.0 + eta * t) # 0.1, 0.0357 and 0.01 at t = 0.9 for eta = 0, 2, 10.
100
-
101
-
102
- # %% 05. Counting the jumps
103
- #
104
- # Writing M(t) for the probability that the position is masked, the two flows cancel so that
105
- # M_dot(t) = -1 for every eta, and M(t) = 1 - t whatever the corrector strength. Integrating the
106
- # unmasking rate against M and the remasking rate against 1 - M gives 1 + eta/2 unmaskings and eta/2
107
- # remaskings, for 1 + eta jumps per position.
108
-
109
-
110
- def masked_fraction_derivative(t, eta, masked=None):
111
- """M_dot(t) = -M (1 + eta t)/(1 - t) + (1 - M) eta, which equals -1 at M = 1 - t."""
112
- masked = 1.0 - t if masked is None else masked # The solution we expect.
113
- return -masked * (1.0 + eta * t) / (1.0 - t) + (1.0 - masked) * eta
114
-
115
-
116
- def simulate_jumps(p1, eta, positions, steps, generator=None):
117
- """Run ``positions`` independent positions from the mask and count their jumps.
118
-
119
- Returns the fraction still masked at t = 0.5 and the mean number of jumps per position.
120
- """
121
- state = torch.full((positions,), MASK, dtype=torch.long) # Every position masked at t = 0.
122
- jumps = torch.zeros(positions, dtype=torch.int32) # One counter per position.
123
- h, halfway = 1.0 / steps, None # The increment, and the masked fraction recorded at t = 0.5.
124
- for k in range(steps):
125
- t = k * h
126
- masked = state == MASK # [P] which positions are still at the mask symbol.
127
- if halfway is None and t >= 0.5:
128
- halfway = float(masked.to(torch.get_default_dtype()).mean()) # M(0.5), which the chapter puts at 1 - t.
129
- up = min(1.0, h * (1.0 + eta * t) / max(1.0 - t, 1e-12)) # Unmasking probability this step.
130
- down = h * eta # Remasking probability this step.
131
- coin = torch.rand(positions, generator=generator)
132
- unmask = masked & (coin < up) # Masked positions that reveal a token.
133
- remask = (~masked) & (coin < down) # Visible positions that return to the mask.
134
- revealed = int(unmask.sum()) # Draw a token only where one is needed.
135
- if revealed:
136
- tokens = torch.multinomial(p1, revealed, replacement=True, generator=generator)
137
- state = state.masked_scatter(unmask, tokens) # Split over tokens by the denoiser.
138
- state = state.masked_fill(remask, MASK) # The circulation back to the mask symbol.
139
- jumps += (unmask | remask).to(torch.int32) # Every reveal and remasking counts as a jump.
140
- return halfway, float(jumps.double().mean()) # Near 1 - t = 0.5, and near 1 + eta.
141
-
142
-
143
- # %% 06. The parallel-step error
144
- #
145
- # Let the data be uniform over AA and TT and the source be all masks. With both positions masked the
146
- # denoiser at each is (0.5, 0.5), and a step that reveals both at once draws them independently, so
147
- # half of those draws land outside the data. With eta > 0 a pair revealed jointly is remasked at rate
148
- # 2 eta and redrawn with the other position visible, where the denoiser is a point mass.
149
-
150
-
151
- def joint_reveal_rate(steps):
152
- """The probability that the two reveal times land in the same cell of a uniform grid, h."""
153
- return 1.0 / steps # Sum over cells of h squared, which is 1/64 at 64 steps.
154
-
155
-
156
- def pair_survival(eta, tau=0.5):
157
- """exp(-2 eta (1 - tau)), the chance a pair revealed at tau is untouched at t = 1."""
158
- return math.exp(-2.0 * eta * (1.0 - tau)) # 0.135 at eta = 2 and 4.54e-5 at eta = 10.
159
-
160
-
161
- def simulate_survival(eta, samples, steps, tau=0.5, generator=None):
162
- """The fraction of pairs revealed at ``tau`` that neither position remasks before t = 1.
163
-
164
- Each of the two positions returns to the mask at rate eta, so the first remasking of either
165
- happens at rate 2 eta, and a pair that is remasked is redrawn with the other position visible.
166
- """
167
- remaining = max(1, int(steps * (1.0 - tau))) # Grid cells left between tau and t = 1.
168
- h = (1.0 - tau) / remaining # The increment, matching the grid the sampler runs on.
169
- untouched = torch.ones(samples, dtype=torch.bool) # Pairs no remasking has reached.
170
- for _ in range(remaining):
171
- coin = torch.rand(samples, 2, generator=generator) # One clock per position.
172
- untouched &= ~(coin < h * eta).any(dim=1) # Either position remasking ends the survival.
173
- return float(untouched.to(torch.get_default_dtype()).mean()) # Approaches exp(-2 eta (1 - tau)).
174
-
175
-
176
- def simulate_pair(eta, samples, steps, generator=None):
177
- """The AA-or-TT two-position problem under the corrector. Returns the fraction outside the data."""
178
- state = torch.full((samples, 2), MASK, dtype=torch.long) # Both positions masked at t = 0.
179
- h = 1.0 / steps
180
- for k in range(steps):
181
- t, last = k * h, k == steps - 1 # The last step reveals whatever is left.
182
- masked = state == MASK # [S, 2] read before either update, so the two are simultaneous.
183
- other = state.flip(1) # What the opposite position holds, which the denoiser reads.
184
- guess = torch.randint(0, 2, state.shape, generator=generator) # The (0.5, 0.5) denoiser.
185
- drawn = torch.where(other != MASK, other, guess) # A point mass once the partner is visible.
186
- up = 1.0 if last else min(1.0, h * (1.0 + eta * t) / max(1.0 - t, 1e-12))
187
- down = 0.0 if last else h * eta # No remasking on the step that ends the run.
188
- reveal = masked & (torch.rand(state.shape, generator=generator) < up)
189
- remask = (~masked) & (torch.rand(state.shape, generator=generator) < down)
190
- state = torch.where(reveal, drawn, state) # Unmask, in parallel at both positions.
191
- state = torch.where(remask, torch.full_like(state, MASK), state) # The circulation back to m.
192
- return float((state[:, 0] != state[:, 1]).double().mean()) # Outside {AA, TT}.
193
-
194
-
195
- # %% 07. The report
196
- def main():
197
- parser = common_parser(__doc__.split("\n")[0])
198
- parser.add_argument("--eta", type=float, default=2.0, help="corrector strength in the step table")
199
- parser.add_argument("--positions", type=int, default=40_000, help="positions in the simulation")
200
- parser.add_argument("--pairs", type=int, default=20_000, help="sequences in the pair simulation")
201
- args = parser.parse_args()
202
- set_seed(args.seed)
203
-
204
- p, u, v, with_u, with_both = three_state_example()
205
- report = {
206
- "zero_flux": [round(float(f), 6) for f in net_flux(v, p)], # Zero at A, C and G.
207
- "p_dot_u": [round(float(f), 4) for f in with_u], # (-0.2, -0.3, 0.5).
208
- "p_dot_u_plus_v": [round(float(f), 4) for f in with_both], # The same three numbers.
209
- "rate_out_of_A_u": round(float(-u[0, 0]), 4), # 1 under the velocity alone.
210
- "rate_out_of_A_both": round(float(-(u + v)[0, 0]), 4), # 5 once the corrector is added.
211
- }
212
-
213
- # Section 5.14.3: one masked position at t = 0.4 with the chapter's denoiser and h = 0.01.
214
- p1 = torch.tensor([0.1, 0.6, 0.2, 0.1]) # [4] over A, C, G, T.
215
- bar_u = corrector_velocity(p1, MASK, t=0.4, eta=args.eta) # [5] rates out of the mask.
216
- seen = torch.zeros(DNA_SIZE)
217
- seen[1] = 1.0 # At a visible position the true denoiser is the point mass on the visible token.
218
- report["unmask_rate"] = round(float(-bar_u[MASK]), 4) # (1 + eta t)/(1 - t); 3.0 at eta = 2.
219
- report["remask_rate"] = round(float(corrector_velocity(seen, 1, 0.4, args.eta)[MASK]), 4)
220
- report["step_probabilities"] = [round(float(0.01 * r), 5) for r in bar_u[:DNA_SIZE]]
221
- report["stay_masked"] = round(float(1.0 + 0.01 * bar_u[MASK]), 5) # 0.97 at eta = 2.
222
- report["remask_probability"] = round(0.01 * args.eta, 4) # eta h, which is 0.02 at eta = 2.
223
- report["masked_derivative"] = round(masked_fraction_derivative(0.4, args.eta), 6) # -1.
224
- report["admissible_step_at_0.9"] = round(admissible_step(0.9, args.eta), 4) # 0.0357 at eta = 2.
225
-
226
- # The long simulation, with the letter frequency of the synthetic DNA as the denoiser.
227
- splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
228
- counts = torch.bincount(splits["train_tokens"].flatten(), minlength=DNA_SIZE).double()
229
- frequency = counts / counts.sum() # [4] the GC-rich background the lecture shares.
230
- for eta in (0.0, 2.0, 10.0):
231
- half, mean = simulate_jumps(frequency, eta, args.positions, args.steps)
232
- report[f"masked_at_0.5_eta_{eta:g}"] = round(half, 4) # 1 - t = 0.5 at every eta.
233
- report[f"jumps_eta_{eta:g}"] = round(mean, 3) # 1 + eta.
234
-
235
- # Section 5.14.4: the two positions that have to agree.
236
- report["joint_reveal_at_64"] = round(joint_reveal_rate(64), 4) # 0.0156.
237
- report["error_at_64_predicted"] = round(0.5 * joint_reveal_rate(64), 4) # Half of those are wrong.
238
- report["error_at_64_measured"] = f"{simulate_pair(0.0, args.pairs, 64):.5f}" # Near 0.0078.
239
- report["error_at_grid_measured"] = f"{simulate_pair(0.0, args.pairs, args.steps):.5f}"
240
- for eta in (0.0, 2.0, 10.0):
241
- predicted = pair_survival(eta) # exp(-2 eta (1 - tau)) at the midpoint tau = 0.5.
242
- report[f"survival_eta_{eta:g}"] = f"{predicted:.3e}"
243
- if predicted * args.pairs >= 20: # Below that, the simulation has nothing to resolve.
244
- measured = simulate_survival(eta, args.pairs, args.steps) # The same number, simulated.
245
- report[f"survival_eta_{eta:g}_measured"] = f"{measured:.3e}"
246
- print_report("correctors", report)
247
- save_run(args.out, config=vars(args), report=report)
248
-
249
-
250
- if __name__ == "__main__":
251
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_5/dfm.py DELETED
@@ -1,237 +0,0 @@
1
- """Discrete flow matching, the whole construction in one file.
2
-
3
- We pick the probability path first, write down a velocity that generates it, and read the sampler off
4
- the velocity. Each position follows the mixture path between a source token and a data token, its
5
- conditional velocity is a schedule factor times the difference of two point masses, and the posterior
6
- average of those conditional velocities is the same factor times a denoiser. Cross-entropy on the
7
- data token recovers that denoiser, and one Euler step redraws a position from it with probability
8
- lambda_t. Chapter 4 fixed the corruption and solved for its reverse; here the corruption is a
9
- consequence of the path we chose.
10
-
11
- Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
12
- u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
13
-
14
- Chapter 5, Sections 5.5 to 5.11. Trained on the synthetic DNA of ``cis6270.data``. Prints the
15
- chapter's worked numbers, the training loss and the measurements of a generated batch.
16
-
17
- python lecture_5/dfm.py # train and generate from the uniform source
18
- python lecture_5/dfm.py --source mask # the all-mask source instead
19
- python lecture_5/dfm.py --steps 20 --quiet # a few seconds, for a first look
20
- """
21
-
22
- # %% 01. Imports and configuration
23
-
24
- import torch
25
- import torch.nn.functional as F
26
- from torch.distributions import Categorical
27
-
28
- from cis6270.data import DNA_SIZE, decode_dna, dna_splits, encode_dna
29
- from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
30
- from cis6270.nets import SequenceDenoiser, count_parameters
31
- from cis6270.runner import common_parser, print_report, save_run, set_seed, train
32
-
33
-
34
- # %% 02. The class of Section 5.11, with both sources
35
- #
36
- # The constructor holds the vocabulary size V and the schedule kappa_t with its derivative
37
- # kappa_dot_t, passed as functions of a time tensor. The defaults are the linear schedule
38
- # kappa_t = t and kappa_dot_t = 1. A mask source returns the index of the mask symbol at every
39
- # position, with the network's vocabulary raised by one to make room for it.
40
-
41
-
42
- class DiscreteFlowMatching:
43
- """The source, the mixture path, the cross-entropy loss and one sampling step."""
44
-
45
- def __init__(self, V, kappa=lambda t: t, kappa_dot=lambda t: torch.ones_like(t), mask=None):
46
- self.V, self.k, self.kd = V, kappa, kappa_dot # Alphabet size and the schedule.
47
- self.mask = mask # Mask token id under the mask source, None under the uniform source.
48
-
49
- def source(self, shape, device="cpu"):
50
- if self.mask is None:
51
- return torch.randint(0, self.V, shape, device=device) # Uniform source, [B, L].
52
- return torch.full(shape, self.mask, dtype=torch.long, device=device) # All-mask source.
53
-
54
- def corrupt(self, x1, t):
55
- """One draw from p_t(. | x_0, x_1) by the coin flip of Section 5.5.5. x1: [B, L], t: [B]."""
56
- x0 = self.source(x1.shape, x1.device) # The other end of the pair; [B, L].
57
- heads = torch.rand(x1.shape, device=x1.device) < self.k(t)[:, None] # Heads with prob kappa_t.
58
- return torch.where(heads, x1, x0) # The data token on heads and the source token on tails.
59
-
60
-
61
- # %% 03. The conditional velocity
62
- #
63
- # Differentiating the conditional path at one position gives kappa_dot_t times the difference of the
64
- # two indicators, and dividing that flux by the probability 1 - kappa_t still sitting on the source
65
- # token turns it into a rate out of the token the position currently holds.
66
-
67
-
68
- def conditional_velocity(z, x1, t, V=DNA_SIZE):
69
- """u_t(a, z | x_1) = kappa_dot/(1 - kappa) [delta_{x_1}(a) - delta_z(a)]. Returns [V]."""
70
- factor = 1.0 / (1.0 - t) # Schedule factor on the linear schedule, kappa_dot_t = 1.
71
- return factor * (F.one_hot(torch.tensor(x1), V) - F.one_hot(torch.tensor(z), V)).double()
72
-
73
-
74
- # %% 04. The posterior average, collapsed onto the data token
75
- #
76
- # The conditional velocity depends on the pair only through the data token at this position, so every
77
- # pair sharing that token contributes the same term and the sum over pairs collapses onto the four
78
- # tokens. What is left is the denoiser p_{1|t}(. | x), the only quantity a network has to learn.
79
-
80
-
81
- def marginal_velocity(p1, x_i, t, V=DNA_SIZE):
82
- """u^i_t(., x) = kappa_dot/(1 - kappa) [p_{1|t}(. | x) - delta_{x^i}]. p1: [V] -> [V]."""
83
- factor = 1.0 / (1.0 - t) # Same schedule factor, now multiplying a distribution.
84
- return factor * (p1 - F.one_hot(torch.tensor(x_i), V).double()) # Signed, sums to zero.
85
-
86
-
87
- def mask_posterior(data, weights, visible, kappa_t):
88
- """The posterior over the data token at the masked position, computed by enumeration.
89
-
90
- ``data`` is a list of candidate sequences and ``weights`` their probabilities under q. Position 0
91
- is visible and holds ``visible``; position 1 is masked. The time factor is common to every
92
- candidate and cancels, which is Proposition 6 of Discrete Flow Matching.
93
- """
94
- masked, seen = 1, 1 # One masked position and one visible position in this example.
95
- factor = (1.0 - kappa_t) ** masked * kappa_t ** seen # Common to every surviving candidate.
96
- unnormalized = torch.zeros(DNA_SIZE, dtype=torch.double) # One entry per base at position 1.
97
- for sequence, weight in zip(data, weights):
98
- if sequence[0] == visible: # A candidate disagreeing with the visible token is excluded.
99
- unnormalized[encode_dna([sequence])[0, 1]] += factor * weight
100
- return unnormalized / unnormalized.sum() # [V] the denoiser at the masked position.
101
-
102
-
103
- # %% 05. The cross-entropy loss and its minimizer
104
- #
105
- # The loss scores the true data token at every position, with no time weight and no rate in it, so the
106
- # sampler is chosen after training. Averaging over the data token given X_t splits the expected loss
107
- # into the entropy of the true denoiser and a KL divergence from it to the network, and only the
108
- # divergence depends on theta.
109
-
110
-
111
- def dfm_loss(logits, x1):
112
- """Cross-entropy of Section 5.9, with the class dimension second. logits: [B, L, V]."""
113
- return F.cross_entropy(logits.transpose(1, 2), x1) # Mean over the batch and over positions.
114
-
115
-
116
- def entropy_and_gap(posterior, prediction):
117
- """The minimum of the loss at one position and the excess a wrong prediction pays, in nats."""
118
- entropy = -(posterior * posterior.log()).sum() # H(p_{1|t}), the floor no network beats.
119
- cross = -(posterior * prediction.log()).sum() # The cross-entropy the prediction scores.
120
- return float(entropy), float(cross - entropy) # The gap is the KL divergence to the prediction.
121
-
122
-
123
- # %% 06. The Euler step as a two-part rule
124
- #
125
- # Substituting the marginal velocity into the per-position Euler step collects the two terms into a
126
- # mixture of the current point mass and the denoiser. With probability lambda_t the position redraws
127
- # from the denoiser, and with probability 1 - lambda_t it keeps the token it holds.
128
-
129
-
130
- def redraw_probability(t, h):
131
- """lambda_t = h kappa_dot_t / (1 - kappa_t), the redraw probability of Section 5.8."""
132
- return h / (1.0 - t) # Linear schedule; above one the sampler redraws with certainty.
133
-
134
-
135
- @torch.no_grad()
136
- def dfm_step(proc, model, xt, t, h):
137
- """One two-part step at every position at once. xt: [B, L], t: [B], h a scalar."""
138
- p1 = model(xt, t)[..., :proc.V].softmax(-1) # [B, L, V] denoiser, the only network call.
139
- rate = proc.kd(t) / (1 - proc.k(t)).clamp(min=1e-4) # kappa_dot/(1 - kappa), kept finite at t=1.
140
- redraw = torch.rand(xt.shape, device=xt.device) < (h * rate)[:, None] # [B, L] Bernoulli.
141
- x1 = Categorical(probs=p1).sample() # [B, L] one candidate token per position.
142
- return torch.where(redraw, x1, xt) # Keep the candidate where the coin came up true.
143
-
144
-
145
- # %% 07. Training and generation
146
- #
147
- # A training step draws one time per sequence, corrupts the batch at that time, and scores the true
148
- # tokens. Generation walks a uniform grid of ``sample_steps`` times from t = 0 to t = 1, and the grid
149
- # is free to change because training never chose a step size.
150
-
151
-
152
- @torch.no_grad()
153
- def generate(proc, model, batch, length, steps, device="cpu"):
154
- """Run the sampler from the source to t = 1 and return the tokens. [B, L]."""
155
- x = proc.source((batch, length), device) # Draw from the source distribution p.
156
- grid = torch.linspace(0.0, 1.0, steps + 1, device=device) # The sampling times.
157
- for t, s in zip(grid[:-1], grid[1:]):
158
- x = dfm_step(proc, model, x, t.expand(batch), s - t) # One forward pass per step.
159
- return x
160
-
161
-
162
- # %% 08. The chapter's worked numbers
163
- def worked_numbers():
164
- """The arithmetic Sections 5.5.5, 5.7.4, 5.8, 5.9.2 and 5.9.3 carry out by hand."""
165
- report = {}
166
-
167
- # Section 5.5.5: x_1 = ACGT, a uniform source draw x_0 = GTTA, kappa_t = 0.5, coins HTHT.
168
- x1, x0 = encode_dna(["ACGT"])[0], encode_dna(["GTTA"])[0] # [4] each.
169
- heads = torch.tensor([True, False, True, False]) # The four coins of the example.
170
- xt = torch.where(heads, x1, x0) # Data token on heads, source token on tails.
171
- report["mixture_draw"] = decode_dna(xt[None])[0] # ATGA.
172
-
173
- # Section 5.7.4: one position holding G, denoiser (0.1, 0.6, 0.2, 0.1), t = 0.5.
174
- p1 = torch.tensor([0.1, 0.6, 0.2, 0.1], dtype=torch.double) # [V] over A, C, G, T.
175
- u = marginal_velocity(p1, x_i=2, t=0.5) # Factor 1/0.5 = 2 times the bracket.
176
- report["velocity_at_G"] = [round(float(v), 4) for v in u] # (0.2, 1.2, -1.6, 0.2).
177
- report["velocity_sums_to"] = f"{float(u.sum()):.1e}" # Zero, which is the second rule.
178
- step = F.one_hot(torch.tensor(2), DNA_SIZE).double() + 0.1 * u # One Euler step of size h = 0.1.
179
- report["euler_step_at_G"] = [round(float(v), 4) for v in step] # (0.02, 0.12, 0.84, 0.02).
180
- report["redraw_probability"] = round(redraw_probability(0.5, 0.1), 4) # lambda_t = 0.2.
181
-
182
- # Section 5.9.2: the noisy input ATGA scored against the target ACGT.
183
- scored = torch.tensor([0.7, 0.2, 0.6, 0.1], dtype=torch.double) # One probability per position.
184
- report["example_loss_nats"] = round(float(-scored.log().sum()), 3) # 4.780 nats.
185
-
186
- # Section 5.9.3: length two, the mask source, q(AC) = 0.5, q(AG) = 0.3, q(GC) = 0.2.
187
- candidates, weights = ["AC", "AG", "GC"], [0.5, 0.3, 0.2]
188
- half = mask_posterior(candidates, weights, visible="A", kappa_t=0.5) # Denoiser at kappa = 0.5.
189
- late = mask_posterior(candidates, weights, visible="A", kappa_t=0.8) # The same at kappa = 0.8.
190
- report["mask_posterior_C_G"] = [round(float(half[1]), 4), round(float(half[2]), 4)]
191
- report["mask_posterior_unchanged"] = bool(torch.allclose(half, late)) # The time cancels.
192
- live = half[half > 0] # The two bases the posterior keeps.
193
- entropy, gap = entropy_and_gap(live, torch.tensor([0.5, 0.5], dtype=torch.double))
194
- report["posterior_entropy_nats"] = round(entropy, 4) # 0.6616, the floor on the loss.
195
- report["kl_gap_to_uniform"] = round(gap, 4) # 0.0316 above that floor.
196
- return report
197
-
198
-
199
- # %% 09. The report
200
- def main():
201
- parser = common_parser(__doc__.split("\n")[0])
202
- parser.add_argument("--source", choices=["uniform", "mask"], default="uniform")
203
- parser.set_defaults(steps=1000, width=64, samples=64)
204
- args = parser.parse_args()
205
- set_seed(args.seed)
206
-
207
- splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
208
- data = splits["train_tokens"] # [N, L] the shared synthetic DNA.
209
- mask = DNA_SIZE if args.source == "mask" else None # Index 4 is the mask symbol m.
210
- vocab = DNA_SIZE + 1 if mask is not None else DNA_SIZE # The network's vocabulary.
211
- proc = DiscreteFlowMatching(DNA_SIZE, mask=mask) # Linear schedule by default.
212
- model = SequenceDenoiser(vocab, args.length, width=args.width, depth=2)
213
-
214
- def loss_fn(step):
215
- rows = torch.randint(0, len(data), (args.batch_size,)) # One minibatch of data sequences.
216
- x1 = data[rows] # [B, L] the data end of the pair.
217
- t = torch.rand(args.batch_size) # One time per sequence, uniform on [0, 1].
218
- xt = proc.corrupt(x1, t) # [B, L] a draw from the conditional path.
219
- return dfm_loss(model(xt, t)[..., :DNA_SIZE], x1) # Score the true token at every position.
220
-
221
- if not args.quiet:
222
- print(f"source={args.source} vocab={vocab} parameters={count_parameters(model):,}")
223
- losses = train(model, loss_fn, steps=args.steps, lr=args.lr, quiet=args.quiet)
224
- tokens = generate(proc, model, args.samples, args.length, args.sample_steps)
225
-
226
- report = worked_numbers()
227
- report["final_loss_nats"] = sum(losses[-20:]) / len(losses[-20:]) # Averaged over the last steps.
228
- report["gc_fraction"] = gc_fraction(tokens) # The training data sit at 0.577.
229
- report["motif_fraction"] = motif_fraction(tokens) # Sequences carrying the planted ACGT.
230
- report["unique_fraction"] = unique_fraction(tokens) # Catches a collapsed sampler.
231
- report["token_entropy_nats"] = token_entropy(tokens, DNA_SIZE) # At most log 4 = 1.386.
232
- print_report(f"discrete flow matching, {args.source} source", report, decode_dna(tokens))
233
- save_run(args.out, model, vars(args), report, losses, decode_dna(tokens))
234
-
235
-
236
- if __name__ == "__main__":
237
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_5/dirichlet.py DELETED
@@ -1,262 +0,0 @@
1
- """Dirichlet Flow Matching: a token relaxed into a point of the simplex.
2
-
3
- A straight line toward a corner shrinks its support as t grows, and past t = 1/k at most k - 1
4
- corners remain possible, which makes the denoiser target discontinuous in time and in space.
5
- Replacing the line by the Dirichlet family Dir(1 + t e_i) keeps every corner possible at every finite
6
- time. The velocity of that path points at the data corner and changes only its speed, and matching
7
- the probability flux across the cut x_i = c solves for that speed.
8
-
9
- Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
10
- u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y). On this path the label x_1
11
- still means the data sample while its subscript stops meaning time one, since time runs to infinity
12
- and inference stops at t = 8.
13
-
14
- Chapter 5, Sections 5.19.3 and 5.20.1 to 5.20.5. Trained on the synthetic DNA of ``cis6270.data``.
15
- Prints the worked speeds, the continuity check, the posterior average, and the measurements of a
16
- generated batch.
17
-
18
- python lecture_5/dirichlet.py # train, then 100 Euler steps to t = 8
19
- python lecture_5/dirichlet.py --steps 20 --quiet # a few seconds, for a first look
20
- """
21
-
22
- # %% 01. Imports and configuration
23
- import math
24
-
25
- import torch
26
- import torch.nn as nn
27
- import torch.nn.functional as F
28
- from scipy.special import betainc, betaln
29
-
30
- from cis6270.data import DNA_SIZE, decode_dna, dna_splits
31
- from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
32
- from cis6270.nets import SequenceDenoiser, count_parameters
33
- from cis6270.runner import common_parser, print_report, save_run, set_seed, train
34
-
35
- T_MAX = 8.0 # The inference horizon, where the data coordinate has mean (t+1)/(t+K) = 0.75 for DNA.
36
- EDGE = 1e-6 # The speed divides by (1 - x_i) and by x_i, so a coordinate is held off both ends.
37
-
38
-
39
- # %% 02. The Dirichlet family as a conditional path
40
- #
41
- # With the concentration 1 + t in the data coordinate and 1 everywhere else, every exponent except
42
- # the i-th is zero and the density collapses to a single factor. At t = 0 the path is the uniform
43
- # distribution on the simplex, and for every finite t it is positive wherever x_i is, so no corner is
44
- # ever ruled out.
45
-
46
-
47
- def dirichlet_density(x, i, t, K=DNA_SIZE):
48
- """p_t(x | x_1 = e_i) = Gamma(t + K)/Gamma(t + 1) x_i^t."""
49
- prefactor = math.exp(math.lgamma(t + K) - math.lgamma(t + 1.0)) # Gamma(4) = 6 at t = 0.
50
- return prefactor * float(x[i]) ** t # Constant over the simplex at t = 0.
51
-
52
-
53
- def sample_dirichlet(targets, t, K=DNA_SIZE):
54
- """Draw x ~ Dir(1 + t e_i) at every position. targets: [B, L], t: [B] -> [B, L, K]."""
55
- alpha = torch.ones(*targets.shape, K) + t[:, None, None] * F.one_hot(targets, K) # [B, L, K].
56
- gamma = torch._standard_gamma(alpha) # One Gamma draw per letter, with unit rate.
57
- return gamma / gamma.sum(-1, keepdim=True) # Normalizing the Gamma draws gives a Dirichlet.
58
-
59
-
60
- def straight_line_corners(x, t):
61
- """How many corners the straight-line path still allows at x, which is the count of x_i >= t."""
62
- return int((x >= t).sum()) # Proposition 5.5 bounds this by k - 1 once t > 1/k.
63
-
64
-
65
- # %% 03. The velocity by matching flux across a cut
66
- #
67
- # Cutting the simplex at x_i = c, the mass below the cut is the Beta(t+1, K-1) distribution function
68
- # at c, and its rate of change is the derivative of that function in its first parameter. Setting the
69
- # loss rate equal to minus the outward flux and solving for the speed leaves one scalar per
70
- # coordinate, with no closed form at general K.
71
-
72
-
73
- def d_betainc_da(x, a, b, eps=1e-6):
74
- """tilde I_x(a, b), the derivative of the regularized incomplete beta function in a."""
75
- return (betainc(a + eps, b, x) - betainc(a - eps, b, x)) / (2.0 * eps) # A central difference.
76
-
77
-
78
- def speed(x_i, t, K=DNA_SIZE):
79
- """C(x_i, t) = -tilde I_{x_i}(t+1, K-1) B(t+1, K-1) / ((1 - x_i)^{K-1} x_i^t).
80
-
81
- The data coordinate may be a scalar or a tensor of any shape, since every factor broadcasts,
82
- and a whole batch of relaxed tokens costs the two calls to the incomplete beta function that
83
- one costs. The central difference subtracts two numbers that agree to about six decimals, and
84
- at single precision nothing of the difference survives, so this one quantity is computed in
85
- double whatever the ambient dtype and the caller casts the result back.
86
- """
87
- a, b = t + 1.0, float(K - 1) # The two Beta parameters of the data coordinate.
88
- beta = math.exp(betaln(a, b)) # The complete beta function, 1/12 at a = 2 and b = 3.
89
- held = torch.as_tensor(x_i, dtype=torch.double).clamp(EDGE, 1.0 - EDGE) # Off both endpoints.
90
- derivative = torch.as_tensor(d_betainc_da(held.numpy(), a, b)) # Negative, so the speed is not.
91
- return -derivative * beta / ((1.0 - held) ** (K - 1) * held ** t) # Positive.
92
-
93
-
94
- def conditional_velocity(x, i, t):
95
- """u_t(x | x_1 = e_i) = C(x_i, t)(e_i - x), tangent to the simplex. x: [K] -> [K]."""
96
- direction = F.one_hot(torch.tensor(i), len(x)).to(x) - x # Coordinates summing to zero.
97
- return float(speed(float(x[i]), t, len(x))) * direction # The speed scales the direction.
98
-
99
-
100
- # %% 04. Two letters, in closed form
101
- #
102
- # With K = 2 the second Beta parameter is one, the incomplete beta function is x^a, and the speed
103
- # comes out in elementary functions. Differentiating the density in time and the flux in space then
104
- # cancels term by term, which is the continuity equation at K = 2.
105
-
106
-
107
- def speed_two_letters(x, t):
108
- """C(x_1, t) = -x_1 log x_1 / ((t + 1)(1 - x_1)), the K = 2 case of the general formula."""
109
- return -x * math.log(x) / ((t + 1.0) * (1.0 - x)) # Positive on the whole open interval.
110
-
111
-
112
- def continuity_terms(x, i, t, h=1e-2):
113
- """partial_t p and div(p u) in the chart (x_1, ..., x_{K-1}), whose sum the path drives to zero."""
114
- x = x.to(torch.double) # A stencil on top of a numerical derivative needs the extra digits.
115
- K = len(x)
116
-
117
- def density(point):
118
- return dirichlet_density(point, i, t, K) # The Dirichlet density at a simplex point.
119
-
120
- def chart(point, coordinate):
121
- return density(point) * float(conditional_velocity(point, i, t)[coordinate]) # p u_j.
122
-
123
- time = density(x) * (_digamma(t + K) - _digamma(t + 1.0) + math.log(float(x[i]))) # partial_t p.
124
- divergence = 0.0
125
- for j in range(K - 1): # The chart drops the last coordinate, which the sum constraint fixes.
126
- step = torch.zeros(K, dtype=x.dtype)
127
- step[j], step[K - 1] = 1.0, -1.0 # A direction inside the simplex, keeping the sum at one.
128
- divergence += ( # A five-point stencil, since the speed itself is a numerical derivative.
129
- -chart(x + 2 * h * step, j) + 8 * chart(x + h * step, j)
130
- - 8 * chart(x - h * step, j) + chart(x - 2 * h * step, j)
131
- ) / (12.0 * h)
132
- return time, divergence # Equal and opposite to the precision of the difference.
133
-
134
-
135
- def _digamma(z):
136
- """The digamma function, used only to differentiate the Gamma prefactor in time."""
137
- return float(torch.digamma(torch.tensor(z, dtype=torch.double)))
138
-
139
-
140
- # %% 05. A worked speed on a DNA base
141
- #
142
- # A coordinate already holding a large share has a larger speed, so the flow concentrates gradually
143
- # and the coordinate in the lead moves fastest toward its corner.
144
-
145
-
146
- def speed_vector(x, t):
147
- """C(x_k, t) at every letter of one relaxed base. x: [K] -> [K]."""
148
- return speed(x, t, x.shape[-1]).to(x) # One vectorized evaluation, cast back to x's dtype.
149
-
150
-
151
- # %% 06. The posterior average and the loss
152
- #
153
- # The data corner is unknown at generation time, so the four conditional velocities combine with the
154
- # denoiser as weights. Each one is tangent to the simplex and a weighted average of tangent vectors
155
- # is tangent, so the learned field needs no constraint. Cross-entropy on the data token trains the
156
- # denoiser, and the conditional velocity appears only inside the sampler.
157
-
158
-
159
- def marginal_velocity(x, p_hat, t):
160
- """v_t(x) = sum_k u_t(x | e_k) p_{1|t}(e_k | x). x: [K], p_hat: [K] -> [K]."""
161
- columns = torch.stack([conditional_velocity(x, k, t) for k in range(len(x))], dim=1) # [K, K].
162
- return columns @ p_hat # The posterior average of the four conditional velocities.
163
-
164
-
165
- def batched_velocity(x, p_hat, t):
166
- """The same average at every position of a batch. x, p_hat: [B, L, K] -> [B, L, K]."""
167
- speeds = speed(x, t, x.shape[-1]).to(x) # [B, L, K] one speed per coordinate.
168
- weighted = speeds * p_hat # [B, L, K] the coefficient of each conditional velocity.
169
- return weighted - weighted.sum(-1, keepdim=True) * x # sum_k w_k (e_k - x), collected.
170
-
171
-
172
- def relaxed_denoiser(length, width, K=DNA_SIZE, depth=2):
173
- """The shared sequence denoiser with its lookup table replaced by a map on the relaxed token."""
174
- model = SequenceDenoiser(K, length, width=width, depth=depth)
175
- model.token_embedding = nn.Linear(K, width, bias=False) # A point of the simplex, not an index.
176
- return model
177
-
178
-
179
- # %% 07. Training on DNA, and sampling by Euler steps
180
- #
181
- # Training draws t from an exponential with rate one, places a point on the conditional path, and
182
- # scores the true letter. Generation starts from the uniform Dirichlet and integrates the marginal
183
- # velocity to t = 8, projecting back onto the simplex after each step.
184
-
185
-
186
- @torch.no_grad()
187
- def generate(model, batch, length, steps, K=DNA_SIZE):
188
- """Integrate the marginal velocity from the uniform simplex to t = T_MAX. Returns [B, L]."""
189
- gamma = torch._standard_gamma(torch.ones(batch, length, K)) # Dir(1) is the uniform source.
190
- x = gamma / gamma.sum(-1, keepdim=True) # [B, L, K].
191
- h = T_MAX / steps # The time increment, 0.08 over 100 steps.
192
- for k in range(steps):
193
- t = k * h
194
- p_hat = model(x, torch.full((batch,), t)).softmax(-1) # [B, L, K] the denoiser.
195
- x = x + h * batched_velocity(x, p_hat, t) # One Euler step along the marginal velocity.
196
- x = x.clamp(min=1e-8) # A large step can push a coordinate below zero.
197
- x = x / x.sum(-1, keepdim=True) # Back onto the simplex.
198
- return x.argmax(-1) # Read the token off the largest coordinate.
199
-
200
-
201
- # %% 08. The report
202
- def main():
203
- parser = common_parser(__doc__.split("\n")[0])
204
- parser.set_defaults(steps=1000, width=64, samples=64, sample_steps=100)
205
- args = parser.parse_args()
206
- set_seed(args.seed)
207
-
208
- report = {
209
- "density_at_t_0": round(dirichlet_density(torch.full((4,), 0.25), 0, 0.0), 4), # 6.
210
- "corners_left_at_0.3": straight_line_corners(torch.tensor([0.34, 0.33, 0.33, 0.0]), 0.3),
211
- "corners_left_at_0.6": straight_line_corners(torch.tensor([0.70, 0.30, 0.0, 0.0]), 0.6),
212
- "speed_K2_closed": f"{speed_two_letters(0.3, 1.0):.7f}", # 0.2579942.
213
- "speed_K2_general": f"{float(speed(0.3, 1.0, K=2)):.7f}", # The same to six decimals.
214
- }
215
- point = torch.tensor([0.5, 0.3, 0.2], dtype=torch.double) # A base of three letters, at t = 0.8.
216
- time_term, space_term = continuity_terms(point, 0, 0.8)
217
- report["continuity_time_term"] = round(time_term, 4) # 0.6355.
218
- report["continuity_space_term"] = round(space_term, 4) # -0.6355.
219
- report["continuity_residual"] = f"{time_term + space_term:.2e}" # Their sum, near zero.
220
-
221
- # Section 5.20.4: K = 4, t = 1 and the relaxed base (0.40, 0.30, 0.20, 0.10).
222
- x = torch.tensor([0.40, 0.30, 0.20, 0.10], dtype=torch.double)
223
- report["speeds"] = [round(float(c), 3) for c in speed_vector(x, 1.0)] # 0.232, 0.199, 0.159, 0.104.
224
- toward_a = conditional_velocity(x, 0, 1.0) # [4] the velocity toward the A corner.
225
- report["velocity_toward_A"] = [round(float(v), 3) for v in toward_a]
226
- report["velocity_sums_to"] = f"{float(toward_a.sum()):.1e}" # Zero, so the step stays on it.
227
-
228
- # Section 5.20.5: the same base with the denoiser (0.60, 0.25, 0.10, 0.05).
229
- p_hat = torch.tensor([0.60, 0.25, 0.10, 0.05], dtype=torch.double)
230
- v = marginal_velocity(x, p_hat, 1.0)
231
- report["marginal_velocity"] = [round(float(c), 4) for c in v] # (0.0553, -0.0133, -0.0262, -0.0158).
232
- report["euler_step_h_0.1"] = [round(float(c), 4) for c in x + 0.1 * v] # (0.4055, ...).
233
- report["step_sums_to"] = round(float((x + 0.1 * v).sum()), 9) # One, still on the simplex.
234
-
235
- # Training on the shared DNA, then generation.
236
- splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
237
- data = splits["train_tokens"] # [N, L].
238
- model = relaxed_denoiser(args.length, args.width)
239
-
240
- def loss_fn(step):
241
- rows = torch.randint(0, len(data), (args.batch_size,)) # One minibatch of data sequences.
242
- x1 = data[rows] # [B, L] the letters the denoiser has to recover.
243
- t = torch.distributions.Exponential(1.0).sample((args.batch_size,)) # t ~ Exp(1).
244
- xt = sample_dirichlet(x1, t) # [B, L, K] a point on the conditional path.
245
- return F.cross_entropy(model(xt, t).transpose(1, 2), x1) # Score the true letter.
246
-
247
- if not args.quiet:
248
- print(f"parameters={count_parameters(model):,} horizon={T_MAX}")
249
- losses = train(model, loss_fn, steps=args.steps, lr=args.lr, quiet=args.quiet)
250
- tokens = generate(model, args.samples, args.length, args.sample_steps)
251
-
252
- report["final_loss_nats"] = sum(losses[-20:]) / len(losses[-20:])
253
- report["gc_fraction"] = gc_fraction(tokens) # The training data sit at 0.577.
254
- report["motif_fraction"] = motif_fraction(tokens) # Sequences carrying the planted ACGT.
255
- report["unique_fraction"] = unique_fraction(tokens) # Catches a collapsed sampler.
256
- report["token_entropy_nats"] = token_entropy(tokens, DNA_SIZE) # At most log 4 = 1.386.
257
- print_report("Dirichlet flow matching", report, decode_dna(tokens))
258
- save_run(args.out, model, vars(args), report, losses, decode_dna(tokens))
259
-
260
-
261
- if __name__ == "__main__":
262
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_5/edit_flows.py DELETED
@@ -1,295 +0,0 @@
1
- """Edit Flows: paths whose sequences change length.
2
-
3
- Every path so far keeps a single length, because a substitution changes a token and never adds or
4
- removes one. Enlarging the set of moves to insertions, deletions and substitutions removes that
5
- constraint. An alignment pads the two ends with a blank until they have the same number of columns,
6
- the mixture construction then applies column by column, and the alignment is an auxiliary variable we
7
- marginalize exactly as we marginalized the data sequence. The loss scores rates in place of a
8
- distribution over tokens, and its minimizer is the rate at which an edit makes progress.
9
-
10
- Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
11
- u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
12
-
13
- Chapter 5, Section 5.17. Runs on the synthetic DNA of ``cis6270.data``, trimmed to varying lengths.
14
- Prints the alignment of the chapter, the posterior over two alignments, the averaged edit rates, the
15
- loss of one example, one sampler step, and the length statistics of a run on DNA.
16
-
17
- python lecture_5/edit_flows.py # 600 sampler steps on 256 DNA pairs
18
- python lecture_5/edit_flows.py --steps 20 --quiet # a coarse grid, a visibly coarser length
19
- """
20
-
21
- # %% 01. Imports and configuration
22
- import math
23
-
24
- import torch
25
-
26
- from cis6270.data import DNA, DNA_SIZE, dna_splits, encode_dna
27
- from cis6270.runner import common_parser, print_report, save_run, set_seed
28
-
29
- BLANK = DNA_SIZE # Index 4 is the blank symbol epsilon that padding writes into a column.
30
-
31
-
32
- # %% 02. Three edits and the rate split
33
- #
34
- # At each position of the current sequence there are three edits, and each rate splits into an
35
- # intensity and a distribution over tokens. A deletion writes no token, so the network emits three
36
- # intensities and two token distributions per position, which is 3 + 2K numbers in place of the K of
37
- # the denoiser.
38
-
39
-
40
- def edit_rates(intensities, tokens=DNA_SIZE):
41
- """Count the numbers a network emits per position, and the total rate of leaving the sequence."""
42
- total = sum(sum(kind) for kind in intensities.values()) # -u_t(x, x), the total exit rate.
43
- return 3 + 2 * tokens, total # Three intensities and two token distributions per position.
44
-
45
-
46
- # %% 03. Alignments and the column-wise path
47
- #
48
- # An alignment puts the positions of the two sequences in correspondence by padding both with the
49
- # blank. A blank in the target column is a deletion, a blank in the source column an insertion, and
50
- # two different tokens a substitution. On the aligned pair each column follows the mixture path
51
- # independently, and removing the blanks from z_t gives the sequence x_t the model reads.
52
-
53
-
54
- def align(x0, x1):
55
- """A minimal alignment of two token lists, as the padded pair (z_0, z_1) of equal length."""
56
- n, m = len(x0), len(x1)
57
- cost = [[0] * (m + 1) for _ in range(n + 1)] # Edit distance with unit costs.
58
- for i in range(n + 1):
59
- cost[i][0] = i # Deleting the first i tokens of the source.
60
- for j in range(m + 1):
61
- cost[0][j] = j # Inserting the first j tokens of the target.
62
- for i in range(1, n + 1):
63
- for j in range(1, m + 1):
64
- cost[i][j] = min(cost[i - 1][j] + 1, cost[i][j - 1] + 1,
65
- cost[i - 1][j - 1] + int(x0[i - 1] != x1[j - 1]))
66
- z0, z1, i, j = [], [], n, m
67
- while i or j: # One backtrace through the table, taking the diagonal where it is minimal.
68
- if i and j and cost[i][j] == cost[i - 1][j - 1] + int(x0[i - 1] != x1[j - 1]):
69
- z0.append(x0[i - 1]), z1.append(x1[j - 1]), (i := i - 1), (j := j - 1)
70
- elif i and cost[i][j] == cost[i - 1][j] + 1:
71
- z0.append(x0[i - 1]), z1.append(BLANK), (i := i - 1) # A deletion column.
72
- else:
73
- z0.append(BLANK), z1.append(x1[j - 1]), (j := j - 1) # An insertion column.
74
- return z0[::-1], z1[::-1] # [C] and [C] over the same number of columns.
75
-
76
-
77
- def column_kinds(z0, z1):
78
- """Name the edit each column owes: ``del``, ``ins``, ``sub``, or ``none`` when the two agree."""
79
- kinds = []
80
- for a, b in zip(z0, z1):
81
- kinds.append("none" if a == b else "del" if b == BLANK else "ins" if a == BLANK else "sub")
82
- return kinds # [C] one label per column.
83
-
84
-
85
- def draw_state(z0, z1, finished):
86
- """z_t from a choice of which columns already hold their target, with the blanks removed."""
87
- held = [b if done else a for a, b, done in zip(z0, z1, finished)] # [C] the aligned state.
88
- return [token for token in held if token != BLANK] # [|x_t|] the sequence the model reads.
89
-
90
-
91
- def expected_length(x0, x1, kappa_t):
92
- """E[|x_t|] = (1 - kappa_t)|x_0| + kappa_t|x_1|, the interpolation one position up."""
93
- return (1.0 - kappa_t) * len(x0) + kappa_t * len(x1) # Linear from |x_0| to |x_1|.
94
-
95
-
96
- # %% 04. Averaging over the alignments we cannot see
97
- #
98
- # Many alignments produce the same pair, and x_t does not record which one the path is on. Bayes'
99
- # rule over the alignments weights each by the probability that its column draw produces the observed
100
- # x_t, and the rates average against those weights. No single alignment has to be chosen.
101
-
102
-
103
- def alignment_posterior(alignments, observed, kappa_t):
104
- """p_t(A | x_t) over a list of aligned pairs, by enumerating the column draws of each."""
105
- weights = []
106
- for z0, z1 in alignments:
107
- owed = [i for i, kind in enumerate(column_kinds(z0, z1)) if kind != "none"] # Unfinished.
108
- total = 0.0
109
- for mask in range(1 << len(owed)): # Every subset of the owed columns already finished.
110
- finished = [True] * len(z0)
111
- probability = 1.0
112
- for bit, column in enumerate(owed):
113
- done = bool(mask >> bit & 1)
114
- finished[column] = done
115
- probability *= kappa_t if done else 1.0 - kappa_t # The column-wise mixture path.
116
- if draw_state(z0, z1, finished) == observed:
117
- total += probability # This draw is consistent with what we see.
118
- weights.append(total)
119
- norm = sum(weights)
120
- return [w / norm for w in weights] # One posterior weight per alignment.
121
-
122
-
123
- def averaged_rates(alignments, observed, kappa_t, kappa_dot=1.0):
124
- """The marginal rate of each edit that finishes a column, averaged over the alignments.
125
-
126
- ``observed`` is the draw in which every column that owes an edit still holds its source symbol,
127
- which is the state the worked example of Section 5.17.2 reads the rates at.
128
- """
129
- rate = kappa_dot / (1.0 - kappa_t) # The weight on every unfinished column.
130
- posterior, out = alignment_posterior(alignments, observed, kappa_t), {}
131
- for (z0, z1), weight in zip(alignments, posterior):
132
- held = [b if kind == "none" else a # A column owing nothing is finished at every time.
133
- for a, b, kind in zip(z0, z1, column_kinds(z0, z1))]
134
- for i, (a, b, kind) in enumerate(zip(z0, z1, column_kinds(z0, z1))):
135
- seen = sum(1 for token in held[:i] if token != BLANK) # Position inside x_t.
136
- if kind == "sub":
137
- name = f"sub({seen + 1},{DNA[b]})" # Replace the token at that position.
138
- elif kind == "ins":
139
- name = f"ins({seen},{DNA[b]})" # Place a token to the right of that position.
140
- elif kind == "del":
141
- name = f"del({seen + 1})" # Remove the token at that position.
142
- else:
143
- continue # Nothing is owed here.
144
- out[name] = out.get(name, 0.0) + weight * rate
145
- return posterior, out
146
-
147
-
148
- # %% 05. The training loss
149
- #
150
- # The first term is the sum of the model's intensities and pushes all of them down. The second is a
151
- # weighted cross-entropy on the edits that move x_t toward x_1 and pushes those up. Collecting the
152
- # terms belonging to one edit gives lambda - w log lambda, whose stationary point is lambda = w.
153
-
154
-
155
- def edit_loss(total_intensity, owed, kappa_t, kappa_dot=1.0):
156
- """L = sum of all intensities - sum over unfinished columns of the weight times log lambda."""
157
- weight = kappa_dot / (1.0 - kappa_t) # kappa_dot/(1 - kappa), which is 2 at t = 0.5.
158
- return total_intensity - sum(weight * math.log(lam) for lam in owed) # The model's own rates.
159
-
160
-
161
- def loss_minimizer(weight, grid):
162
- """The intensity minimizing lambda - w log lambda on a grid, which the derivative puts at w."""
163
- values = [lam - weight * math.log(lam) for lam in grid] # The contribution of one edit.
164
- return grid[min(range(len(grid)), key=values.__getitem__)] # Near w, with w/lambda^2 > 0.
165
-
166
-
167
- # %% 06. One step of the sampler
168
- #
169
- # Each position inserts with probability h lambda_ins, and deletes or substitutes with probability
170
- # h(lambda_del + lambda_sub), the two sharing that draw in proportion to their intensities. Every
171
- # chosen edit is applied at once, so the step has the same second-order error as the parallel
172
- # step on a fixed length.
173
-
174
-
175
- def step_probabilities(intensities, h):
176
- """The per-position insertion and delete-or-substitute probabilities of one Euler step."""
177
- insert = [h * lam for lam in intensities["ins"]] # One coin per position.
178
- change = [h * (d + s) for d, s in zip(intensities["del"], intensities["sub"])] # A second coin.
179
- return insert, change
180
-
181
-
182
- def untouched_probability(intensities, h):
183
- """The chance the step applies no edit anywhere, and the first-order estimate beside it."""
184
- insert, change = step_probabilities(intensities, h)
185
- exact = math.prod(1.0 - p for p in insert + change) # Every coin coming up tails.
186
- _, total = edit_rates(intensities)
187
- return exact, 1.0 - h * total # The gap is the second-order term from two edits at once.
188
-
189
-
190
- def expected_length_change(intensities, h):
191
- """h times the insertion intensities minus the deletion intensities, in tokens."""
192
- return h * (sum(intensities["ins"]) - sum(intensities["del"])) # Nothing fixes the length.
193
-
194
-
195
- # %% 07. Edits on the synthetic DNA
196
- #
197
- # Each pair of sequences is aligned once, every column that owes an edit finishes at rate
198
- # kappa_dot/(1 - kappa), and the sequence the model reads is the aligned state with its blanks
199
- # removed. The realized length wanders while its mean follows the interpolation.
200
-
201
-
202
- def simulate_columns(pairs, steps, generator=None):
203
- """Run the column-wise path on a list of aligned pairs. Returns the lengths and the end states."""
204
- width = max(len(z0) for z0, _ in pairs) # Pad every alignment to the widest one.
205
- src = torch.full((len(pairs), width), BLANK)
206
- tgt = torch.full((len(pairs), width), BLANK)
207
- valid = torch.zeros(len(pairs), width, dtype=torch.bool)
208
- for row, (z0, z1) in enumerate(pairs):
209
- src[row, :len(z0)] = torch.tensor(z0)
210
- tgt[row, :len(z1)] = torch.tensor(z1)
211
- valid[row, :len(z0)] = True # Columns beyond the alignment take no part.
212
- done = (src == tgt) & valid # A column whose two symbols agree owes no edit.
213
- h, halfway = 1.0 / steps, None
214
- for k in range(steps):
215
- t = k * h
216
- if halfway is None and t >= 0.5:
217
- held = torch.where(done, tgt, src) # [P, C] the aligned state at this time.
218
- halfway = float(((held != BLANK) & valid).sum(dim=1).double().mean())
219
- lam = 1.0 if k == steps - 1 else min(1.0, h / max(1.0 - t, 1e-12)) # lambda_t per column.
220
- coin = torch.rand(src.shape, generator=generator) < lam
221
- done = done | (coin & valid) # An unfinished column takes its target symbol.
222
- finished = float((done | ~valid).all(dim=1).double().mean()) # Fraction of pairs that arrived.
223
- return halfway, finished
224
-
225
-
226
- # %% 08. The report
227
- def main():
228
- parser = common_parser(__doc__.split("\n")[0])
229
- parser.add_argument("--pairs", type=int, default=256, help="DNA pairs aligned and run")
230
- args = parser.parse_args()
231
- set_seed(args.seed)
232
-
233
- # Section 5.17.1: the chapter's alignment of ACGT with AGAC over five columns.
234
- x0, x1 = encode_dna(["ACGT"])[0].tolist(), encode_dna(["AGAC"])[0].tolist()
235
- z0 = x0 + [BLANK] # (A, C, G, T, eps).
236
- z1 = [x1[0], BLANK] + x1[1:] # (A, eps, G, A, C).
237
- kinds = column_kinds(z0, z1)
238
- draw = [True, False, True, True, True] # Column 2 behind, columns 4 and 5 already at the target.
239
- report = {
240
- "column_kinds": kinds, # none, del, none, sub, ins.
241
- "draw_at_kappa_0.5": "".join(DNA[token] for token in draw_state(z0, z1, draw)), # ACGAC.
242
- "draw_probability": round(0.5 ** sum(kind != "none" for kind in kinds), 4), # 0.125.
243
- "expected_length": round(expected_length(x0, x1, 0.5), 4), # 4, both ends having length 4.
244
- "numbers_per_position": edit_rates({"ins": [0.0], "del": [0.0], "sub": [0.0]})[0], # 11.
245
- }
246
-
247
- # Section 5.17.2: the pair AC and AAC, which admits two alignments on three columns.
248
- short, long = encode_dna(["AC"])[0].tolist(), encode_dna(["AAC"])[0].tolist()
249
- first = ([short[0], short[1], BLANK], long) # Substitute C with A, then insert C.
250
- second = ([short[0], BLANK, short[1]], long) # Insert A, and leave the last column alone.
251
- observed = short # The noisy sequence x_t = AC.
252
- for kappa_t in (0.25, 0.5, 0.75):
253
- weights = alignment_posterior([first, second], observed, kappa_t)
254
- report[f"posterior_A1_kappa_{kappa_t}"] = round(weights[0], 4) # (1-kappa)/(2-kappa).
255
- posterior, rates = averaged_rates([first, second], observed, 0.5)
256
- for name in sorted(rates):
257
- report[f"rate_{name}"] = round(rates[name], 3) # 0.667, 1.333 and 0.667.
258
- report["total_target_rate"] = round(sum(rates.values()), 3) # 2.667.
259
-
260
- # Section 5.17.3: one unfinished column, a model intensity of 0.8, and 3.0 over all edits.
261
- report["example_loss"] = round(edit_loss(3.0, [0.8], kappa_t=0.5), 4) # 3.446.
262
- grid = [0.01 * i for i in range(1, 801)] # Intensities between 0.01 and 8.
263
- report["loss_minimizer"] = round(loss_minimizer(2.0, grid), 2) # 2, the weight the edit receives.
264
-
265
- # Section 5.17.4: one whole step on ACG with the chapter's nine intensities.
266
- intensities = {"ins": [0.2, 0.3, 0.4], "del": [0.1, 0.9, 0.2], "sub": [0.4, 0.3, 0.5]}
267
- exact, first_order = untouched_probability(intensities, 0.05)
268
- insert, change = step_probabilities(intensities, 0.05)
269
- report["insert_probabilities"] = [round(p, 3) for p in insert] # 0.010, 0.015, 0.020.
270
- report["change_probabilities"] = [round(p, 3) for p in change] # 0.025, 0.060, 0.035.
271
- report["deletion_given_change"] = round(0.9 / (0.9 + 0.3), 4) # 0.75, three times in four.
272
- report["untouched"] = round(exact, 4) # 0.8452.
273
- report["untouched_first_order"] = round(first_order, 4) # 0.835.
274
- report["second_order_gap"] = round(exact - first_order, 4) # 0.0102.
275
- report["length_change"] = round(expected_length_change(intensities, 0.05), 4) # -0.015.
276
-
277
- # The same path on the shared DNA, trimmed so that the two ends have different lengths.
278
- splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
279
- tokens = splits["train_tokens"]
280
- cuts = torch.randint(args.length - 4, args.length + 1, (2 * args.pairs,)) # Varying lengths.
281
- rows = [tokens[i % len(tokens), :cuts[i]].tolist() for i in range(2 * args.pairs)]
282
- pairs = [align(rows[2 * i], rows[2 * i + 1]) for i in range(args.pairs)]
283
- predicted = sum(expected_length(rows[2 * i], rows[2 * i + 1], 0.5) for i in range(args.pairs))
284
- measured, arrived = simulate_columns(pairs, args.steps)
285
- report["dna_columns_mean"] = round(sum(len(z0) for z0, _ in pairs) / len(pairs), 3)
286
- report["dna_length_at_0.5"] = round(measured, 3) # Measured over the run.
287
- report["dna_length_predicted"] = round(predicted / args.pairs, 3) # The interpolation.
288
- report["dna_pairs_arrived"] = round(arrived, 4) # Every column finishes by t = 1.
289
- report["dna_example"] = " -> ".join("".join(DNA[t] for t in row) for row in rows[:2])
290
- print_report("edit flows", report)
291
- save_run(args.out, config=vars(args), report=report)
292
-
293
-
294
- if __name__ == "__main__":
295
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_5/fisher.py DELETED
@@ -1,257 +0,0 @@
1
- """Fisher-Flow: the simplex measured by the metric statistics already uses for distributions.
2
-
3
- The straight-line distance on the simplex measures absolute differences in probability, so dividing a
4
- rare letter's probability by ten counts for almost nothing. The Fisher information measures how
5
- distinguishable two nearby distributions are, and by Chentsov's theorem it is the only metric under
6
- which relabeling the letters or merging two of them is an isometry. Taking the square root of every
7
- coordinate turns it into the ordinary metric on a sphere, where the conditional path is a
8
- great-circle arc and the velocity is the remaining arc divided by the remaining time.
9
-
10
- Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
11
- u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
12
-
13
- Chapter 5, Sections 5.21.1 to 5.21.5, with the listing of Section 5.21.7. Trained on the synthetic
14
- DNA of ``cis6270.data``. Prints the two distances, the worked arc on a DNA base, the tangent
15
- projection, and the measurements of a generated batch.
16
-
17
- python lecture_5/fisher.py # train, then 64 exponential-map steps
18
- python lecture_5/fisher.py --steps 20 --quiet # a few seconds, for a first look
19
- """
20
-
21
- # %% 01. Imports and configuration
22
- import math
23
-
24
- import torch
25
- import torch.nn as nn
26
-
27
- from cis6270.data import DNA_SIZE, decode_dna, dna_splits
28
- from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
29
- from cis6270.nets import SequenceDenoiser, count_parameters
30
- from cis6270.runner import common_parser, print_report, save_run, set_seed, train
31
-
32
- SMOOTHING = 0.03 # Label smoothing that holds a data corner off the boundary, where the metric blows up.
33
-
34
-
35
- # %% 02. Small probabilities under the straight-line distance
36
- #
37
- # Moving a base from (0.5, 0.5) to (0.4, 0.6) shifts a tenth of the mass between two evenly matched
38
- # letters. Moving from (0.01, 0.99) to (0.001, 0.999) divides the first probability by ten, which
39
- # matters for a model of sequences, and the Euclidean distance calls the second move eleven times
40
- # smaller.
41
-
42
-
43
- def euclidean_distance(p, q):
44
- """The straight-line distance between two probability vectors."""
45
- return float((p - q).norm()) # 0.1414 and 0.0127 for the two moves above.
46
-
47
-
48
- def fisher_rao_distance(p, q):
49
- """d_FR(p, q) = 2 arccos(<sqrt(p), sqrt(q)>), at most pi for any two distributions."""
50
- inner = float((p.sqrt() * q.sqrt()).sum().clamp(max=1.0)) # The angle between square roots.
51
- return 2.0 * math.acos(inner) # 0.201 and 0.137 for the same two moves.
52
-
53
-
54
- # %% 03. The Fisher information as a local metric
55
- #
56
- # With the probabilities as the parameters, the derivative of the log likelihood of outcome i with
57
- # respect to p^j is delta_ij/p^j, and the expectation over outcomes collapses the two deltas onto the
58
- # diagonal. Dividing by p^i makes a change in a rare coordinate count for more.
59
-
60
-
61
- def fisher_matrix(p):
62
- """g_jk(p) = delta_jk / p^j, the Fisher information of a categorical distribution. [K, K]."""
63
- return torch.diag(1.0 / p) # Diagonal, with the reciprocal of each probability.
64
-
65
-
66
- def fisher_inner(u, v, p):
67
- """<u, v>_p = sum_i u^i v^i / p^i, the Fisher-Rao inner product of two tangent vectors at p."""
68
- return float((u * v / p).sum()) # Tangent means the coordinates sum to zero.
69
-
70
-
71
- # %% 04. The square-root map to the sphere
72
- #
73
- # The simplex constraint becomes a norm constraint, so the square roots lie on the unit sphere, in
74
- # the octant with every coordinate nonnegative. Differentiating the square root shows the squared
75
- # Euclidean length there is one quarter of the Fisher-Rao length, so the map scaled by two is an
76
- # isometry.
77
-
78
-
79
- def to_sphere(p):
80
- """A probability vector becomes a unit vector. The clamp holds a zero off the boundary."""
81
- return p.clamp(min=1e-8).sqrt() # simplex -> sphere.
82
-
83
-
84
- def to_simplex(x):
85
- """A unit vector with nonnegative coordinates becomes a probability vector."""
86
- return x ** 2 # sphere -> simplex.
87
-
88
-
89
- def smooth(one_hot, amount=SMOOTHING):
90
- """Move a corner inside the simplex, which the metric needs since it diverges at the boundary."""
91
- K = one_hot.shape[-1]
92
- return one_hot * (1.0 - K * amount) + amount # (0.91, 0.03, 0.03, 0.03) at amount 0.03.
93
-
94
-
95
- # %% 05. Geodesics on the sphere
96
- #
97
- # The shortest path between two points of a sphere is the arc of the great circle through them, and
98
- # the conditional path is that arc traversed at constant speed. The logarithm map has length equal to
99
- # the remaining arc, so dividing by the remaining time leaves the speed constant along the path.
100
-
101
-
102
- def log_map(x, y):
103
- """The tangent vector at x reaching y in unit time, of length equal to the arc from x to y."""
104
- c = (x * y).sum(-1, keepdim=True).clamp(max=1 - 1e-6) # The cosine of the angle between them.
105
- v = y - c * x # orthogonal to x.
106
- return v * torch.arccos(c) / v.norm(dim=-1, keepdim=True) # Rescaled to the arc length.
107
-
108
-
109
- def exp_map(x, w):
110
- """The point reached by moving along the sphere from x in the direction w for a distance ||w||."""
111
- n = w.norm(dim=-1, keepdim=True).clamp(min=1e-8) # The length of the tangent vector.
112
- return torch.cos(n) * x + torch.sin(n) * (w / n) # Unit length by cos^2 + sin^2 = 1.
113
-
114
-
115
- def geodesic(x0, x1, t):
116
- """x_t = exp_{x_0}(t log_{x_0}(x_1)), the point a fraction t of the way along the arc."""
117
- return exp_map(x0, t * log_map(x0, x1)) # the conditional path.
118
-
119
-
120
- def conditional_velocity(xt, x1, t):
121
- """u_t(x_t | x_1) = log_{x_t}(x_1)/(1 - t), the remaining arc over the remaining time."""
122
- return log_map(xt, x1) / (1 - t) # Constant in length along the whole path.
123
-
124
-
125
- # %% 06. The tangent projection and the regression loss
126
- #
127
- # A linear output layer emits an arbitrary vector of R^K, and a velocity on the sphere has to be
128
- # orthogonal to the current point. Subtracting the component along x enforces that in one line, and
129
- # the minimizer of the squared loss is the average of the conditional velocities passing through the
130
- # point, which is the averaging every path in this chapter performs.
131
-
132
-
133
- def project(xt, raw):
134
- """v_theta = raw - <x_t, raw> x_t, which is orthogonal to x_t since ||x_t|| = 1."""
135
- return raw - (xt * raw).sum(-1, keepdim=True) * xt # Any architecture can be used.
136
-
137
-
138
- def loss(model, x0, x1, t):
139
- """The squared distance between the projected network output and the geodesic target."""
140
- xt = geodesic(x0, x1, t[:, None, None]) # [B, L, K] a point on the arc at the sampled time.
141
- u = log_map(xt, x1) / (1 - t[:, None, None]) # [B, L, K] the remaining arc over the time left.
142
- raw = model(xt, t) # [B, L, K] unconstrained.
143
- v = raw - (xt * raw).sum(-1, keepdim=True) * xt # The tangent part of that output.
144
- return ((v - u) ** 2).sum(-1).mean() # Summed over letters, averaged over positions and batch.
145
-
146
-
147
- def velocity_network(length, width, K=DNA_SIZE, depth=2):
148
- """The shared sequence denoiser with its lookup table replaced by a map on the relaxed token.
149
-
150
- The head emits a vector of R^K here, which ``project`` turns into a velocity on the sphere.
151
- """
152
- model = SequenceDenoiser(K, length, width=width, depth=depth)
153
- model.token_embedding = nn.Linear(K, width, bias=False) # A point on the sphere, not an index.
154
- return model
155
-
156
-
157
- # %% 07. Training on DNA, and sampling along the sphere
158
- #
159
- # Source points are uniform draws from the simplex, data points are smoothed corners, and the pair is
160
- # joined by the arc between their square roots. Generation starts from a fresh source point and takes
161
- # exponential-map steps, so every state stays on the sphere and no renormalization is needed.
162
-
163
-
164
- def source_points(batch, length, K=DNA_SIZE):
165
- """Uniform draws from the simplex, mapped to the sphere. Returns [B, L, K]."""
166
- gamma = torch._standard_gamma(torch.ones(batch, length, K)) # Dir(1) over the simplex.
167
- return to_sphere(gamma / gamma.sum(-1, keepdim=True)) # [B, L, K] on the unit sphere.
168
-
169
-
170
- @torch.no_grad()
171
- def generate(model, batch, length, steps, K=DNA_SIZE):
172
- """Integrate the learned field from the source to t = 1 with exponential-map steps. [B, L]."""
173
- x = source_points(batch, length, K) # [B, L, K].
174
- h = 1.0 / steps
175
- for k in range(steps):
176
- t = torch.full((batch,), k * h)
177
- v = project(x, model(x, t)) # [B, L, K] the tangent part of the network output.
178
- x = exp_map(x, h * v) # Move along the sphere, which keeps the norm at one.
179
- return to_simplex(x).argmax(-1) # Read the token off the largest coordinate.
180
-
181
-
182
- # %% 08. The report
183
- def main():
184
- parser = common_parser(__doc__.split("\n")[0])
185
- parser.set_defaults(steps=1000, width=64, samples=64)
186
- args = parser.parse_args()
187
- set_seed(args.seed)
188
-
189
- near = torch.tensor([0.5, 0.5]), torch.tensor([0.4, 0.6]) # Two evenly matched letters.
190
- rare = torch.tensor([0.01, 0.99]), torch.tensor([0.001, 0.999]) # One rare letter, divided by ten.
191
- report = {
192
- "euclidean_near": round(euclidean_distance(*near), 4), # 0.1414.
193
- "euclidean_rare": round(euclidean_distance(*rare), 4), # 0.0127, eleven times smaller.
194
- "fisher_rao_near": round(fisher_rao_distance(*near), 3), # 0.201.
195
- "fisher_rao_rare": round(fisher_rao_distance(*rare), 3), # 0.137, comparable.
196
- "two_letters_are_orthogonal": round(
197
- fisher_rao_distance(torch.tensor([1.0, 0.0]), torch.tensor([0.0, 1.0])), 4), # pi.
198
- }
199
-
200
- # Section 5.21.3: a uniform base and a smoothed A, taken to the sphere.
201
- p0 = torch.full((DNA_SIZE,), 1.0 / DNA_SIZE) # The uniform base.
202
- p1 = smooth(torch.eye(DNA_SIZE)[0]) # (0.91, 0.03, 0.03, 0.03).
203
- x0, x1 = to_sphere(p0), to_sphere(p1)
204
- report["fisher_diagonal"] = [round(float(v), 2) for v in torch.diagonal(fisher_matrix(p0))]
205
- report["sphere_x1"] = [round(float(v), 3) for v in x1] # (0.954, 0.173, 0.173, 0.173).
206
- report["inner_product"] = round(float((x0 * x1).sum()), 3) # 0.737.
207
- report["angle"] = round(math.acos(float((x0 * x1).sum())), 3) # 0.743.
208
- report["fisher_rao_distance"] = round(fisher_rao_distance(p0, p1), 3) # 1.485, twice the angle.
209
-
210
- # Section 5.21.4: the midpoint of the arc, and one exponential-map step from it.
211
- mid = geodesic(x0, x1, torch.tensor(0.5))
212
- report["midpoint_sphere"] = [round(float(v), 3) for v in mid] # (0.780, 0.361, 0.361, 0.361).
213
- report["midpoint_simplex"] = [round(float(v), 3) for v in to_simplex(mid)] # (0.609, 0.130, ...).
214
- remaining = log_map(mid, x1)
215
- report["log_map_from_mid"] = [round(float(v), 4) for v in remaining] # (0.2323, -0.1672, ...).
216
- report["remaining_arc"] = round(float(remaining.norm()), 4) # 0.3713, half the total.
217
- report["constant_speed"] = round(float(conditional_velocity(mid, x1, 0.5).norm()), 3) # 0.743.
218
- stepped = exp_map(mid, 0.1 * conditional_velocity(mid, x1, 0.5)) # Delta = 0.1 from t = 0.5.
219
- report["step_sphere"] = [round(float(v), 3) for v in stepped] # (0.824, 0.327, 0.327, 0.327).
220
- report["step_simplex"] = [round(float(v), 3) for v in to_simplex(stepped)] # (0.680, 0.107, ...).
221
- report["step_norm"] = round(float(stepped.norm()), 10) # One, because the step followed the arc.
222
-
223
- # Section 5.21.5: the tangent projection of one raw network output.
224
- raw = torch.tensor([0.3, -0.2, 0.5, 0.1])
225
- report["raw_inner_product"] = round(float((mid * raw).sum()), 4) # 0.3785.
226
- tangent = project(mid, raw)
227
- report["projected"] = [round(float(v), 3) for v in tangent] # (0.005, -0.337, 0.363, -0.037).
228
- report["projection_inner"] = f"{float((mid * tangent).sum()):.1e}" # Zero to machine precision.
229
-
230
- # Training on the shared DNA, then generation.
231
- splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
232
- data = splits["train_tokens"] # [N, L].
233
- model = velocity_network(args.length, args.width)
234
-
235
- def loss_fn(step):
236
- rows = torch.randint(0, len(data), (args.batch_size,)) # One minibatch of data sequences.
237
- target = to_sphere(smooth(torch.eye(DNA_SIZE)[data[rows]])) # [B, L, K] smoothed corners.
238
- start = source_points(args.batch_size, args.length) # [B, L, K] the independent coupling.
239
- t = torch.rand(args.batch_size) * 0.98 # Held off t = 1, where the target divides by zero.
240
- return loss(model, start, target, t)
241
-
242
- if not args.quiet:
243
- print(f"parameters={count_parameters(model):,} smoothing={SMOOTHING}")
244
- losses = train(model, loss_fn, steps=args.steps, lr=args.lr, quiet=args.quiet)
245
- tokens = generate(model, args.samples, args.length, args.sample_steps)
246
-
247
- report["final_loss"] = sum(losses[-20:]) / len(losses[-20:])
248
- report["gc_fraction"] = gc_fraction(tokens) # The training data sit at 0.577.
249
- report["motif_fraction"] = motif_fraction(tokens) # Sequences carrying the planted ACGT.
250
- report["unique_fraction"] = unique_fraction(tokens) # Catches a collapsed sampler.
251
- report["token_entropy_nats"] = token_entropy(tokens, DNA_SIZE) # At most log 4 = 1.386.
252
- print_report("Fisher-Flow", report, decode_dna(tokens))
253
- save_run(args.out, model, vars(args), report, losses, decode_dna(tokens))
254
-
255
-
256
- if __name__ == "__main__":
257
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_5/guidance.py DELETED
@@ -1,234 +0,0 @@
1
- """Guidance: steering a trained flow toward a property by tilting its rates.
2
-
3
- The distribution we want is the data distribution reweighted by the likelihood of a property, whose
4
- normalizer runs over every sequence. A rate is a ratio, so that constant cancels, and the conditioned
5
- process multiplies each rate by the ratio of a value function at the destination to its value at the
6
- current state. A first-order expansion of the logarithm of that ratio replaces one property
7
- evaluation per destination with one backward pass, and an exponent on the ratio gives one strength
8
- parameter, which the classifier-free form reaches from two trained velocities and no classifier.
9
-
10
- Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
11
- u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
12
-
13
- Chapter 5, Sections 5.24 to 5.29, with the listing of Section 5.33. Several objectives at once are in
14
- ``lecture_5/multi_objective.py``. Trained on the synthetic DNA of ``cis6270.data``, with the GC label
15
- as the property. Prints the worked guided base, the guidance strength, the Taylor tilt, and the GC
16
- fraction of a batch generated with and without guidance.
17
-
18
- python lecture_5/guidance.py # train, then sample at gamma = 0 and gamma = 2
19
- python lecture_5/guidance.py --gamma 5 # a harder tilt toward the GC-rich label
20
- python lecture_5/guidance.py --steps 20 --quiet # a few seconds, for a first look
21
- """
22
-
23
- # %% 01. Imports and configuration
24
-
25
- import torch
26
- import torch.nn as nn
27
- import torch.nn.functional as F
28
-
29
- from cis6270.data import DNA_SIZE, decode_dna, dna_splits
30
- from cis6270.metrics import gc_fraction, motif_fraction, token_entropy
31
- from cis6270.nets import SequenceDenoiser, TimeEmbedding, count_parameters
32
- from cis6270.runner import common_parser, print_report, save_run, set_seed, train
33
-
34
- V = DNA_SIZE + 1 # Four bases plus the mask symbol, which the sampler starts from.
35
- MASK = DNA_SIZE # Index 4 is the mask symbol m.
36
-
37
-
38
- def k(t):
39
- """The schedule kappa_t, linear here."""
40
- return t
41
-
42
-
43
- def kd(t):
44
- """Its derivative kappa_dot_t, which the linear schedule holds at one."""
45
- return torch.ones_like(t)
46
-
47
-
48
- # %% 02. Guidance as a tilt of the rates
49
- #
50
- # The conditioned velocity is the unguided one multiplied by the ratio of the property probability at
51
- # the destination to its value at the current state. A move toward a destination the property model
52
- # scores higher happens sooner, and a destination with rate zero stays at rate zero, so the tilt
53
- # reweights the support of the model without enlarging it.
54
-
55
-
56
- def tilt(rates, destination_scores, current_score, gamma=1.0):
57
- """u^(gamma)_t(y | x, c) = [p(c | y, t)/p(c | x, t)]^gamma u_t(y, x)."""
58
- return rates * (destination_scores / current_score) ** gamma # gamma = 1 is the exact process.
59
-
60
-
61
- def h_value(denoiser, destination_scores):
62
- """h_t(x) = E[p(c | X_1) | X_t = x], the posterior average of the property likelihood."""
63
- return float((denoiser * destination_scores).sum()) # The exact value at a masked position.
64
-
65
-
66
- def classifier_free(conditional, unconditional, gamma):
67
- """u_c^gamma u_u^(1-gamma), the geometric mixture that reaches the same one-parameter family."""
68
- return conditional ** gamma * unconditional ** (1.0 - gamma) # Linear in the logarithms.
69
-
70
-
71
- def reachable(positions, letters):
72
- """D(S - 1) + 1, the number of states one jump can reach, since exactly one position changes."""
73
- return positions * (letters - 1) + 1 # 2001 for a 100-residue peptide over 21 symbols.
74
-
75
-
76
- # %% 03. A first-order expansion of the log ratio
77
- #
78
- # Embedding a sequence as one-hot vectors makes a single-position change a sparse difference, and
79
- # expanding the log property probability around the current sequence collapses the inner product onto
80
- # the difference of two gradient coordinates. One forward and one backward pass then serve every
81
- # destination.
82
-
83
-
84
- def taylor_log_ratio(gradient, current):
85
- """[grad log p_phi]_{i,b} - [grad log p_phi]_{i,a} at one position. gradient: [K] -> [K]."""
86
- return gradient - gradient[current] # Zero at the letter in place, whose ratio is one.
87
-
88
-
89
- # %% 04. The guided sampler
90
- #
91
- # One guided step differs from the unguided one at a single line, the factor exp(gamma adv). The
92
- # one-hot encoding has gradients switched on, so the property network can be differentiated against
93
- # letters that are not currently in place, and summing over the batch before differentiating shares
94
- # one backward pass across it. The denoiser is read over the four bases alone, since the data token
95
- # is never the mask, which holds the rate back into the mask at zero and the exit rate at
96
- # kappa_dot/(1 - kappa) when gamma is zero.
97
-
98
-
99
- def guided_step(model, prop, xt, t, h, gamma=1.0):
100
- """One Euler step of the tilted velocity at every position at once. xt: [B, D] -> [B, D]."""
101
- oh = F.one_hot(xt, V).to(torch.get_default_dtype()).requires_grad_()
102
- logp = prop(oh, t).sum() # property on noisy x
103
- g = torch.autograd.grad(logp, oh)[0] # one backward pass
104
- adv = g - (oh * g).sum(-1, keepdim=True) # Taylor ratio
105
- with torch.no_grad():
106
- bases = model(xt, t)[..., :DNA_SIZE].softmax(-1) # denoiser
107
- p1 = F.pad(bases, (0, V - DNA_SIZE)) # the data token is never the mask
108
- rate = kd(t) / (1 - k(t)).clamp(min=1e-4)
109
- u = rate[:, None, None] * p1 * torch.exp(gamma * adv) # every rate tilted
110
- u.scatter_(-1, xt[..., None], 0.0) # no self-transition
111
- jump = torch.rand(xt.shape) < h * u.sum(-1)
112
- y = torch.distributions.Categorical(probs=u).sample()
113
- return torch.where(jump, y, xt)
114
-
115
-
116
- class PropertyModel(nn.Module):
117
- """log p_phi(c | x, t) from a one-hot sequence and a time, trained on noisy intermediate states."""
118
-
119
- def __init__(self, length, width=64):
120
- super().__init__()
121
- self.time_embedding = TimeEmbedding(width, scale=1.0) # The property depends on the time.
122
- self.body = nn.Sequential(
123
- nn.Linear(length * V + width, width), nn.SiLU(), nn.Linear(width, 1),
124
- )
125
-
126
- def forward(self, oh, t):
127
- """oh: [B, D, V] one-hot and differentiable, t: [B] -> [B] log probability of the property."""
128
- h = torch.cat([oh.flatten(1), self.time_embedding(t)], dim=1) # [B, D V + width].
129
- return F.logsigmoid(self.body(h)).squeeze(-1) # [B] log p_phi(c | x, t).
130
-
131
-
132
- # %% 05. Training the denoiser and the property model
133
- #
134
- # The denoiser is the one built earlier in this chapter, trained from the mask source. The property
135
- # model reads the same noisy sequences and predicts the GC label of the clean one, so it is a
136
- # function of an intermediate state and not of a clean sequence.
137
-
138
-
139
- def corrupt(x1, t):
140
- """A draw from the conditional path with the all-mask source. x1: [B, D], t: [B] -> [B, D]."""
141
- heads = torch.rand(x1.shape) < k(t)[:, None] # Heads with probability kappa_t at each position.
142
- return torch.where(heads, x1, torch.full_like(x1, MASK)) # Data token on heads, mask on tails.
143
-
144
-
145
- @torch.no_grad()
146
- def generate(model, prop, batch, length, steps, gamma):
147
- """Run the guided sampler from all masks to t = 1. Returns [B, D]."""
148
- x = torch.full((batch, length), MASK, dtype=torch.long) # The all-mask source.
149
- grid = torch.linspace(0.0, 1.0, steps + 1)
150
- for t, s in zip(grid[:-1], grid[1:]):
151
- with torch.enable_grad(): # The property gradient needs a graph inside the no-grad sampler.
152
- x = guided_step(model, prop, x, t.expand(batch), float(s - t), gamma)
153
- p1 = model(x, torch.ones(batch))[..., :DNA_SIZE].softmax(-1) # The data token is never the mask.
154
- drawn = torch.distributions.Categorical(probs=p1).sample() # lambda_t reaching one on the last step.
155
- return torch.where(x == MASK, drawn, x) # Any position still masked is drawn from the denoiser.
156
-
157
-
158
- # %% 06. The report
159
- def main():
160
- parser = common_parser(__doc__.split("\n")[0])
161
- parser.add_argument("--gamma", type=float, default=2.0, help="guidance strength")
162
- parser.set_defaults(steps=1000, width=64, samples=64)
163
- args = parser.parse_args()
164
- set_seed(args.seed)
165
-
166
- # Section 5.27: a masked base at t = 0.6 with the chapter's denoiser and property scores.
167
- p1 = torch.tensor([0.1, 0.2, 0.3, 0.4], dtype=torch.double) # The denoiser over A, C, G, T.
168
- rates = p1 / (1.0 - 0.6) # The unguided unmasking rates, 2.5 times the denoiser.
169
- scores = torch.tensor([0.10, 0.35, 0.55, 0.25], dtype=torch.double) # p(c | a, t).
170
- guided = tilt(rates, scores, 0.20) # The property model scores the masked state at 0.20.
171
- report = {
172
- "unguided_rates": [round(float(r), 4) for r in rates], # (0.25, 0.50, 0.75, 1.00).
173
- "unguided_exit_rate": round(float(rates.sum()), 4), # 2.50.
174
- "likelihood_ratios": [round(float(r), 4) for r in scores / 0.20], # (0.50, 1.75, 2.75, 1.25).
175
- "guided_rates": [round(float(r), 4) for r in guided], # (0.125, 0.875, 2.063, 1.250).
176
- "guided_exit_rate": round(float(guided.sum()), 4), # 4.3125.
177
- "guided_destination": [round(float(r), 3) for r in guided / guided.sum()], # (0.029, ...).
178
- "bayes_products": [round(float(r), 3) for r in p1 * scores], # (0.010, 0.070, 0.165, 0.100).
179
- "exact_h": round(h_value(p1, scores), 4), # 0.345.
180
- "exit_rate_at_exact_h": round(float(tilt(rates, scores, h_value(p1, scores)).sum()), 4), # 2.5.
181
- "reachable_states": reachable(100, 21), # 2001 destinations for a 100-residue peptide.
182
- }
183
-
184
- # Section 5.28: the same base at guidance strength two, and the classifier-free form.
185
- harder = tilt(rates, scores, 0.20, gamma=2.0)
186
- report["gamma_2_rates"] = [round(float(r), 4) for r in harder] # (0.0625, 1.531, 5.672, 1.563).
187
- report["gamma_2_exit_rate"] = round(float(harder.sum()), 4) # 8.828.
188
- report["gamma_2_destination"] = [round(float(r), 3) for r in harder / harder.sum()]
189
- mixed = classifier_free(tilt(rates, scores, 0.20), rates, gamma=2.0)
190
- report["classifier_free_agrees"] = bool(torch.allclose(mixed, harder)) # The same family.
191
-
192
- # Section 5.29: the Taylor tilt against the exact ratio.
193
- gradient = torch.tensor([-2.10, -1.15, -0.75, -1.30, -1.61], dtype=torch.double) # With the mask.
194
- approximate = taylor_log_ratio(gradient, MASK)[:DNA_SIZE] # The mask is the letter in place.
195
- report["taylor_log_ratios"] = [round(float(r), 3) for r in approximate] # (-0.49, 0.46, ...).
196
- report["exact_log_ratios"] = [round(float(r), 3) for r in (scores / 0.20).log()] # (-0.693, ...).
197
- taylor_rates = rates * approximate.exp()
198
- report["taylor_destination"] = [round(float(r), 3) for r in taylor_rates / taylor_rates.sum()]
199
-
200
- # Training the denoiser and the property model on the shared DNA, then sampling twice.
201
- splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
202
- data, labels = splits["train_tokens"], splits["train_labels"] # [N, L] and [N] the GC label.
203
- model = SequenceDenoiser(V, args.length, width=args.width, depth=2)
204
- prop = PropertyModel(args.length, width=args.width)
205
-
206
- def loss_fn(step):
207
- rows = torch.randint(0, len(data), (args.batch_size,)) # One minibatch of data sequences.
208
- x1 = data[rows] # [B, L] the sequences the denoiser has to recover.
209
- y = labels[rows].to(torch.get_default_dtype()) # [B] the property to steer toward.
210
- t = torch.rand(args.batch_size) # One time per sequence.
211
- xt = corrupt(x1, t) # [B, L] a draw from the conditional path.
212
- denoise = F.cross_entropy(model(xt, t)[..., :DNA_SIZE].transpose(1, 2), x1)
213
- logp = prop(F.one_hot(xt, V).to(torch.get_default_dtype()), t) # [B] the property on x_t.
214
- property_loss = -(y * logp + (1 - y) * torch.log1p(-logp.exp().clamp(max=1 - 1e-6))).mean()
215
- return denoise + property_loss # The two networks share one optimizer here.
216
-
217
- if not args.quiet:
218
- print(f"denoiser={count_parameters(model):,} property={count_parameters(prop):,}")
219
- losses = train(model, loss_fn, steps=args.steps, lr=args.lr, quiet=args.quiet,
220
- extra_parameters=list(prop.parameters()))
221
- plain = generate(model, prop, args.samples, args.length, args.sample_steps, gamma=0.0)
222
- steered = generate(model, prop, args.samples, args.length, args.sample_steps, gamma=args.gamma)
223
-
224
- report["final_loss"] = sum(losses[-20:]) / len(losses[-20:])
225
- report["gc_fraction_unguided"] = gc_fraction(plain) # The training data sit at 0.577.
226
- report[f"gc_fraction_gamma_{args.gamma:g}"] = gc_fraction(steered) # Guidance raises it.
227
- report["motif_fraction_unguided"] = motif_fraction(plain)
228
- report["token_entropy_unguided"] = token_entropy(plain, DNA_SIZE)
229
- print_report("guidance", report, decode_dna(steered))
230
- save_run(args.out, model, vars(args), report, losses, decode_dna(steered))
231
-
232
-
233
- if __name__ == "__main__":
234
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_5/gumbel.py DELETED
@@ -1,226 +0,0 @@
1
- """Gumbel-Softmax Flow Matching: the path comes from the way a token is sampled.
2
-
3
- Sampling a categorical is a deterministic function of noise. We add a Gumbel draw to each log
4
- probability and take the largest. Replacing the argmax by a softmax turns the winner into a point of
5
- the simplex, and cooling the temperature slides that point from the center toward a corner. With the
6
- noise held fixed the trajectory is a smooth curve, its time derivative is the replicator equation of
7
- evolutionary dynamics, and setting the noise to zero at inference collapses the velocity to a
8
- multiple of the direction toward the data corner.
9
-
10
- Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
11
- u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
12
-
13
- Chapter 5, Sections 5.22.1 to 5.22.6. Trained on the synthetic DNA of ``cis6270.data``. Prints the
14
- Gumbel-max frequencies, the annealed path at four times, the replicator velocity, the marginal
15
- velocity late in a trajectory, and the measurements of a generated batch.
16
-
17
- python lecture_5/gumbel.py # train, then 64 Euler steps on the simplex
18
- python lecture_5/gumbel.py --steps 20 --quiet # a few seconds, for a first look
19
- """
20
-
21
- # %% 01. Imports and configuration
22
- import math
23
-
24
- import torch
25
- import torch.nn as nn
26
- import torch.nn.functional as F
27
-
28
- from cis6270.data import DNA_SIZE, decode_dna, dna_splits
29
- from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
30
- from cis6270.nets import SequenceDenoiser, count_parameters
31
- from cis6270.runner import common_parser, print_report, save_run, set_seed, train
32
-
33
- TAU_MAX = 10.0 # The temperature at t = 0, hot enough that every letter holds nearly the same mass.
34
- LAMBDA = 3.0 # The cooling rate, so tau(1) = 10 exp(-3) = 0.498.
35
- BETA = 2.0 # The noise scale; a larger beta makes the paths from one data token spread less.
36
-
37
-
38
- # %% 02. The Gumbel-max trick
39
- #
40
- # A Gumbel variable is the negative logarithm of an exponential clock, so the trick is Chapter 4's
41
- # race between clocks written with logarithms, where the winner is an argmax and the rates are
42
- # logits. All of the randomness sits in the noise, and the logits are ours to move.
43
-
44
-
45
- def gumbel(shape, generator=None):
46
- """g ~ Gumbel(0, 1), as minus the log of minus the log of a uniform draw."""
47
- u = torch.rand(shape, generator=generator).clamp(min=1e-12, max=1 - 1e-12)
48
- return -torch.log(-torch.log(u)) # The distribution function is exp(-exp(-x)).
49
-
50
-
51
- def gumbel_max(probabilities, draws, generator=None):
52
- """Frequencies of argmax_k [log pi_k + g_k] over ``draws`` samples. Returns [K]."""
53
- logits = probabilities.log()[None, :] + gumbel((draws, len(probabilities)), generator)
54
- counts = torch.bincount(logits.argmax(-1), minlength=len(probabilities)).double()
55
- return counts / counts.sum() # Approaches pi itself.
56
-
57
-
58
- # %% 03. The softmax relaxation and a cooling temperature
59
- #
60
- # A softmax of the noisy logits divided by a temperature is a point of the simplex. As the
61
- # temperature falls the point approaches the one-hot of the argmax, and as it grows the point
62
- # approaches the center. The temperature never reaches zero, so the path stays in the interior and
63
- # its density stays defined.
64
-
65
-
66
- def temperature(t, tau_max=TAU_MAX, rate=LAMBDA):
67
- """tau(t) = tau_max exp(-lambda t), which is 10 at t = 0 and 0.498 at t = 1."""
68
- return tau_max * math.exp(-rate * t) # 2.231 at t = 0.5 and 0.672 at t = 0.9.
69
-
70
-
71
- # %% 04. The annealed path toward a data token
72
- #
73
- # Putting the data token into the logits and letting the cooling do the rest gives the conditional
74
- # path. The noise is drawn once and held for the whole trajectory, so the only quantity that changes
75
- # with time is the temperature and the point moves continuously toward the data corner.
76
-
77
-
78
- def annealed_path(logits, t):
79
- """psi_t = softmax((e_k + g/beta)/tau(t)), with the logits already assembled. Returns [..., K]."""
80
- return torch.softmax(logits / temperature(t), dim=-1) # A point of the simplex at every time.
81
-
82
-
83
- def path_logits(targets, noise, K=DNA_SIZE, beta=BETA):
84
- """e_k + g/beta at every position. targets: [B, L], noise: [B, L, K] -> [B, L, K]."""
85
- return F.one_hot(targets, K) + noise / beta # The data indicator plus the scaled Gumbel draw.
86
-
87
-
88
- # %% 05. Differentiating the path: the replicator equation
89
- #
90
- # The only time dependence sits in one over tau, which grows at relative rate lambda. Composing with
91
- # the softmax Jacobian gives the current mass times the gap between a letter's logit and the
92
- # mass-weighted average of all of them, which is the replicator equation with fitness
93
- # lambda ell_i/tau(t).
94
-
95
-
96
- def replicator_velocity(x, logits, t, rate=LAMBDA):
97
- """u_{t,i} = (lambda/tau(t)) x_i (ell_i - sum_j x_j ell_j). x, logits: [..., K] -> [..., K]."""
98
- average = (x * logits).sum(-1, keepdim=True) # The mass-weighted average of the logits.
99
- return (rate / temperature(t)) * x * (logits - average) # Coordinates summing to zero.
100
-
101
-
102
- # %% 06. The inference-time velocity
103
- #
104
- # The sampler has only the current point, so the noise is set to zero. The logits become the data
105
- # indicator, the bracket becomes the gap to the data coordinate, and the velocity collapses to a
106
- # multiple of the direction toward the corner, with the factor x_k(1 - x_k) in the k-th component.
107
-
108
-
109
- def inference_velocity(x, k, t, rate=LAMBDA):
110
- """u_t(x | x_1 = e_k) = (lambda/tau(t)) x_k (e_k - x). x: [..., K] -> [..., K]."""
111
- corner = F.one_hot(torch.tensor(k), x.shape[-1]).to(x) # The data corner.
112
- return (rate / temperature(t)) * x[..., k, None] * (corner - x) # Zero once x_k reaches zero.
113
-
114
-
115
- def marginal_velocity(x, p_hat, t, rate=LAMBDA):
116
- """The posterior average over corners. x, p_hat: [..., K] -> [..., K]."""
117
- weight = (rate / temperature(t)) * x * p_hat # [..., K] the coefficient of each corner.
118
- return weight - weight.sum(-1, keepdim=True) * x # sum_k w_k (e_k - x), collected.
119
-
120
-
121
- # %% 07. Training and the marginal velocity
122
- #
123
- # Cross-entropy on the data token trains the denoiser, as it did for the mixture paths and for the
124
- # Dirichlet path, and the conditional velocity appears nowhere in the gradient. At sampling time the
125
- # marginal velocity is the posterior average, the fourth appearance of the same rule in this chapter.
126
-
127
-
128
- def relaxed_denoiser(length, width, K=DNA_SIZE, depth=2):
129
- """The shared sequence denoiser with its lookup table replaced by a map on the relaxed token."""
130
- model = SequenceDenoiser(K, length, width=width, depth=depth)
131
- model.token_embedding = nn.Linear(K, width, bias=False) # A point of the simplex, not an index.
132
- return model
133
-
134
-
135
- @torch.no_grad()
136
- def generate(model, batch, length, steps, K=DNA_SIZE):
137
- """Integrate the marginal velocity from the center of the simplex to t = 1. Returns [B, L]."""
138
- x = annealed_path(gumbel((batch, length, K)) / BETA, 0.0) # The source, with no data indicator.
139
- h = 1.0 / steps
140
- for k in range(steps):
141
- t = k * h
142
- p_hat = model(x, torch.full((batch,), t)).softmax(-1) # [B, L, K] the denoiser.
143
- x = x + h * marginal_velocity(x, p_hat, t) # One Euler step along the marginal velocity.
144
- x = x.clamp(min=1e-8) # A large step can drive a coordinate below zero.
145
- x = x / x.sum(-1, keepdim=True) # Project back onto the simplex.
146
- return x.argmax(-1) # Read the token off the largest coordinate.
147
-
148
-
149
- # %% 08. The report
150
- def main():
151
- parser = common_parser(__doc__.split("\n")[0])
152
- parser.add_argument("--draws", type=int, default=400_000, help="draws in the Gumbel-max check")
153
- parser.set_defaults(steps=1000, width=64, samples=64)
154
- args = parser.parse_args()
155
- set_seed(args.seed)
156
-
157
- pi = torch.tensor([0.5, 0.2, 0.2, 0.1]) # The categorical of the chapter's quick check.
158
- report = {
159
- "gumbel_max_frequencies": [round(float(v), 4) for v in gumbel_max(pi, args.draws)],
160
- "target_probabilities": [round(float(v), 4) for v in pi], # What the frequencies approach.
161
- "tau_at_0": round(temperature(0.0), 4), # 10.
162
- "tau_at_1": round(temperature(1.0), 4), # 0.498.
163
- }
164
-
165
- # Section 5.22.3: one base annealed toward G with the chapter's fixed noise.
166
- scaled = torch.tensor([0.0, -0.45, 0.40, -0.05]) # g/beta over A, C, G, T.
167
- logits = F.one_hot(torch.tensor(2), DNA_SIZE) + scaled # The data indicator at G plus the noise.
168
- report["path_logits"] = [round(float(v), 2) for v in logits] # (0, -0.45, 1.40, -0.05).
169
- for t in (0.0, 0.5, 0.9, 1.0):
170
- point = annealed_path(logits, t)
171
- report[f"path_at_{t}"] = [round(float(v), 4) for v in point]
172
-
173
- # Section 5.22.4: the replicator velocity at the midpoint.
174
- x = annealed_path(logits, 0.5) # (0.2142, 0.1751, 0.4012, 0.2095).
175
- report["tau_at_0.5"] = round(temperature(0.5), 4) # 2.231.
176
- report["rate_over_tau"] = round(LAMBDA / temperature(0.5), 3) # 1.345.
177
- report["average_logit"] = round(float((x * logits).sum()), 4) # 0.4724.
178
- noisy = replicator_velocity(x, logits, 0.5)
179
- report["replicator_velocity"] = [round(float(v), 3) for v in noisy] # (-0.136, -0.217, 0.5, -0.147).
180
- report["replicator_sums_to"] = f"{float(noisy.sum()):.1e}" # Zero, so the step stays on the simplex.
181
-
182
- # Section 5.22.5: the same point with the noise set to zero.
183
- clean = inference_velocity(x, 2, 0.5)
184
- report["inference_velocity"] = [round(float(v), 3) for v in clean] # (-0.116, -0.094, 0.323, ...).
185
-
186
- # Section 5.22.6: one Euler step late in the trajectory.
187
- late = torch.tensor([0.138, 0.084, 0.647, 0.131]) # The base at t = 0.8.
188
- p_hat = torch.tensor([0.05, 0.10, 0.70, 0.15]) # The denoiser output there.
189
- report["tau_at_0.8"] = round(temperature(0.8), 4) # 0.9072.
190
- coefficients = (LAMBDA / temperature(0.8)) * late * p_hat
191
- report["corner_coefficients"] = [round(float(v), 4) for v in coefficients] # (0.0228, ..., 1.4976).
192
- v = marginal_velocity(late, p_hat, 0.8)
193
- report["marginal_velocity"] = [round(float(c), 3) for c in v] # (-0.200, -0.108, 0.454, -0.146).
194
- report["euler_step_h_0.01"] = [round(float(c), 4) for c in late + 0.01 * v] # (0.1360, ...).
195
-
196
- # Training on the shared DNA, then generation.
197
- splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
198
- data = splits["train_tokens"] # [N, L].
199
- model = relaxed_denoiser(args.length, args.width)
200
-
201
- def loss_fn(step):
202
- rows = torch.randint(0, len(data), (args.batch_size,)) # One minibatch of data sequences.
203
- x1 = data[rows] # [B, L] the letters the denoiser has to recover.
204
- t = torch.rand(args.batch_size) # One time per sequence, uniform on [0, 1].
205
- noise = gumbel((args.batch_size, args.length, DNA_SIZE)) # Held fixed along one trajectory.
206
- cooled = path_logits(x1, noise) / torch.tensor(
207
- [temperature(float(s)) for s in t])[:, None, None] # Each sequence at its own temperature.
208
- xt = cooled.softmax(-1) # [B, L, K] a point on the conditional path.
209
- return F.cross_entropy(model(xt, t).transpose(1, 2), x1) # -log <x_theta, x_1>.
210
-
211
- if not args.quiet:
212
- print(f"parameters={count_parameters(model):,} tau_max={TAU_MAX} lambda={LAMBDA}")
213
- losses = train(model, loss_fn, steps=args.steps, lr=args.lr, quiet=args.quiet)
214
- tokens = generate(model, args.samples, args.length, args.sample_steps)
215
-
216
- report["final_loss_nats"] = sum(losses[-20:]) / len(losses[-20:])
217
- report["gc_fraction"] = gc_fraction(tokens) # The training data sit at 0.577.
218
- report["motif_fraction"] = motif_fraction(tokens) # Sequences carrying the planted ACGT.
219
- report["unique_fraction"] = unique_fraction(tokens) # Catches a collapsed sampler.
220
- report["token_entropy_nats"] = token_entropy(tokens, DNA_SIZE) # At most log 4 = 1.386.
221
- print_report("Gumbel-Softmax flow matching", report, decode_dna(tokens))
222
- save_run(args.out, model, vars(args), report, losses, decode_dna(tokens))
223
-
224
-
225
- if __name__ == "__main__":
226
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_5/kinetic_optimal.py DELETED
@@ -1,231 +0,0 @@
1
- """Kinetic-optimal paths: choosing the velocity, and then the path, by minimizing an energy.
2
-
3
- Several velocities generate the same path and several paths connect the same endpoints, so a number
4
- is needed to choose among them. Written in the flux, both constraints are linear and the energy is
5
- convex, and the minimizer is the probability of the destination times the positive part of a
6
- difference of logarithmic growth rates. The mixture velocity of this chapter turns out to be that
7
- minimizer for the path it generates. Picking a distance on the tokens and cooling a softmax of
8
- negative distances then produces a path that moves probability only toward tokens closer to the data
9
- token.
10
-
11
- Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
12
- u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
13
-
14
- Chapter 5, Sections 5.16.1 to 5.16.5 and 5.16.7, with the geodesic schedule of 5.16.6. The data marginal
15
- of the mask-path check is the letter frequency of the synthetic DNA of ``cis6270.data``. Prints the
16
- kinetic-optimal rates, the worked metric path for DNA, and the energy of two schedules.
17
-
18
- python lecture_5/kinetic_optimal.py # 600 quadrature points per integral
19
- python lecture_5/kinetic_optimal.py --steps 20 --quiet # a coarse rule, visibly less accurate
20
- """
21
-
22
- # %% 01. Imports and configuration
23
- import math
24
-
25
- import torch
26
-
27
- from cis6270.data import DNA, DNA_SIZE, dna_splits
28
- from cis6270.runner import common_parser, print_report, save_run, set_seed
29
-
30
-
31
- torch.set_default_dtype(torch.double) # Everything here is arithmetic, so keep full precision.
32
- MASK = DNA_SIZE # Index 4 is the mask symbol m, which the mask path visits.
33
- TRANSITION = torch.tensor([[0, 2, 1, 2], [2, 0, 2, 1], [1, 2, 0, 2], [2, 1, 2, 0]], dtype=torch.double)
34
- # d = 1 for a transition, A for G or C for T, and d = 2 for a transversion across the two classes.
35
-
36
-
37
- # %% 02. The flux and its energy
38
- #
39
- # The flux along an edge is the velocity times the probability of the state it leaves, and the master
40
- # equation is linear in it. The cost is a weight over the probability of the current state, times the
41
- # squared flux, which in the velocity reads as an expected squared rate under p_t.
42
-
43
-
44
- def flux(u, p):
45
- """j_t(y, x) = u_t(y, x) p_t(x). u: [K, K], p: [K] -> [K, K]."""
46
- return u * p[None, :] # Each column scaled by the probability of its current state.
47
-
48
-
49
- def energy_density(u, p, weight=None):
50
- """sum_{y != x} w_t(y, x) u_t(y, x)^2 p_t(x), the cost at one time written in the velocity."""
51
- off = u - torch.diag(torch.diagonal(u)) # The diagonal holds no flux of its own.
52
- weight = torch.ones_like(u) if weight is None else weight # w = 1 gives the expected squared rate.
53
- return float((weight * off ** 2 * p[None, :]).sum())
54
-
55
-
56
- # %% 03. The velocity of least energy
57
- #
58
- # Minimizing the energy edge by edge under the master equation, with the weight 1/p_t(y) that makes
59
- # the cost symmetric in the two ends of an edge, the multiplier comes out as the logarithmic growth
60
- # rate and the flux becomes p_t(y) p_t(x) times the positive part of its difference. Dividing by
61
- # p_t(x) returns the velocity.
62
-
63
-
64
- def kinetic_optimal(p, p_dot):
65
- """u*_t(y, x) = p_t(y) [d/dt log p_t(y) - d/dt log p_t(x)]_+. p: [K], p_dot: [K] -> [K, K]."""
66
- growth = torch.where(p > 0, p_dot / p.clamp(min=1e-300), torch.zeros_like(p)) # d/dt log p_t.
67
- difference = growth[:, None] - growth[None, :] # [K, K] destination minus current state.
68
- u = p[:, None] * difference.clamp(min=0.0) # Probability moves only toward the faster grower.
69
- u = u * (p[None, :] > 0) # A state with no probability sends none.
70
- u.fill_diagonal_(0.0)
71
- return u - torch.diag(u.sum(dim=0)) # The two rules fix the diagonal.
72
-
73
-
74
- # %% 04. Kinetic optimality of the mixture path
75
- #
76
- # On the conditional mixture path the two growth rates are kappa_dot/kappa at the data token and
77
- # minus kappa_dot/(1 - kappa) at the source token, and their difference collapses to the conditional
78
- # velocity built earlier in this chapter. The marginal mask path behaves the same way.
79
-
80
-
81
- def mask_path(q, kappa_t):
82
- """p_t and p_dot_t over (A, C, G, T, m) for the mask source with data marginal q. Returns two [5]."""
83
- p = torch.cat([kappa_t * q, torch.tensor([1.0 - kappa_t])]) # Tokens share kappa_t, m holds 1-kappa.
84
- p_dot = torch.cat([q.clone(), torch.tensor([-1.0])]) # kappa_dot = 1 on the linear schedule.
85
- return p, p_dot
86
-
87
-
88
- def mixture_rate(kappa_t, kappa_dot=1.0):
89
- """kappa_dot_t/(1 - kappa_t), the conditional velocity out of the source token."""
90
- return kappa_dot / (1.0 - kappa_t) # 2 at t = 0.5 on the linear schedule.
91
-
92
-
93
- # %% 05. A distance on the tokens
94
- #
95
- # The formula for u* accepts any path, so we can design one. Cooling a softmax of negative distances
96
- # concentrates the conditional path on the data token, and the normalizing constant cancels between
97
- # the two logarithms, leaving the probability of the destination times beta_dot times the distance
98
- # the move gains.
99
-
100
-
101
- def metric_path(x1, beta_t, distance=TRANSITION):
102
- """p_t(. | x_1) = softmax(-beta_t d(., x_1)). Returns [K]."""
103
- return torch.softmax(-beta_t * distance[:, x1], dim=0) # Uniform at beta = 0, a point mass later.
104
-
105
-
106
- def metric_velocity(x1, beta_t, beta_dot, distance=TRANSITION):
107
- """u*_t(y, x | x_1) = p_t(y | x_1) beta_dot_t [d(x, x_1) - d(y, x_1)]_+. Returns [K, K]."""
108
- p = metric_path(x1, beta_t, distance) # [K] the path at this time.
109
- d = distance[:, x1] # [K] distance from each token to the data token.
110
- gain = (d[None, :] - d[:, None]).clamp(min=0.0) # [K, K] what the move from x to y gains.
111
- u = p[:, None] * beta_dot * gain # Faster when the step in distance is larger.
112
- u.fill_diagonal_(0.0)
113
- return u - torch.diag(u.sum(dim=0)) # Columns sum to zero.
114
-
115
-
116
- # %% 06. A worked metric path for DNA
117
- #
118
- # A transition swaps a purine for a purine or a pyrimidine for a pyrimidine, and a transversion
119
- # crosses between the two classes, so d = 1 and d = 2 record which mistakes are small. With
120
- # beta_t = t/(1 - t) and the data token G, the path at t = 0.5 favors the purine A over the two
121
- # pyrimidines, and the rates out of C run only toward A and G.
122
-
123
-
124
- def beta_schedule(t):
125
- """beta_t = t/(1 - t) with beta_dot_t = 1/(1 - t)^2, which satisfies beta_0 = 0."""
126
- return t / (1.0 - t), 1.0 / (1.0 - t) ** 2 # 1 and 4 at t = 0.5.
127
-
128
-
129
- def path_derivative(x1, t, h=1e-6, distance=TRANSITION):
130
- """d p_t/dt by a central difference, for checking the velocity against the path it generates."""
131
- forward, backward = metric_path(x1, beta_schedule(t + h)[0], distance), metric_path(
132
- x1, beta_schedule(t - h)[0], distance)
133
- return (forward - backward) / (2.0 * h) # [K], compared against u* p_t.
134
-
135
-
136
- # %% 07. The energy of two schedules
137
- #
138
- # On a two-state conditional mixture path the energy is the time integral of kappa_dot squared over
139
- # kappa times one minus kappa. The linear schedule has a non-integrable singularity at each endpoint,
140
- # so its energy grows with the truncation and never settles. The geodesic schedule has the constant
141
- # integrand pi squared, which is the squared arc length of a quarter great circle.
142
-
143
-
144
- def schedule_energy(kappa, kappa_dot, steps, cut=1e-6):
145
- """The integral of kappa_dot^2/(kappa (1 - kappa)) over [cut, 1 - cut], by a substituted midpoint.
146
-
147
- The grid is uniform in s with t = 1/(1 + exp(-s)), whose Jacobian t(1 - t) cancels the endpoint
148
- singularity exactly for the linear schedule and tames it for any other.
149
- """
150
- span = math.log((1.0 - cut) / cut) # The s range that the truncation [cut, 1 - cut] maps to.
151
- h, total = 2.0 * span / steps, 0.0
152
- for k in range(steps):
153
- s = -span + (k + 0.5) * h # Midpoint of the k-th cell.
154
- t = 1.0 / (1.0 + math.exp(-s)) # Back to the time variable.
155
- k_t = min(max(kappa(t), 1e-12), 1.0 - 1e-12) # Hold the schedule off both endpoints.
156
- total += h * t * (1.0 - t) * kappa_dot(t) ** 2 / (k_t * (1.0 - k_t))
157
- return total
158
-
159
-
160
- LINEAR = (lambda t: t, lambda t: 1.0) # kappa_t = t.
161
- GEODESIC = (lambda t: math.sin(math.pi * t / 2) ** 2, # The arc of Section 5.16.6 read as a schedule.
162
- lambda t: (math.pi / 2) * math.sin(math.pi * t))
163
-
164
-
165
- def geodesic_schedule(t, source_probability=0.0):
166
- """kappa_t(x_1) = 1 - sin^2((1 - t) Omega)/sin^2 Omega with Omega = arccos(sqrt(p(x_1)))."""
167
- omega = math.acos(math.sqrt(source_probability)) # pi/2 under the mask source.
168
- return 1.0 - math.sin((1.0 - t) * omega) ** 2 / math.sin(omega) ** 2
169
-
170
-
171
- # %% 08. The report
172
- def main():
173
- parser = common_parser(__doc__.split("\n")[0])
174
- args = parser.parse_args()
175
- set_seed(args.seed)
176
-
177
- # Section 5.16.3: the mask path at t = 0.5 with the chapter's data marginal.
178
- q = torch.tensor([0.1, 0.6, 0.2, 0.1]) # [4] over A, C, G, T.
179
- p, p_dot = mask_path(q, kappa_t=0.5)
180
- u_star = kinetic_optimal(p, p_dot)
181
- report = {
182
- "mask_path_ACGTm": [round(float(v), 4) for v in p], # (0.05, 0.30, 0.10, 0.05, 0.50).
183
- "growth_at_mask": round(float(p_dot[MASK] / p[MASK]), 4), # -2.
184
- "growth_at_token": round(float(p_dot[1] / p[1]), 4), # +2.
185
- "rate_mask_to_C": round(float(u_star[1, MASK]), 4), # 0.30 times 4, which is 1.2.
186
- "mixture_rate_to_C": round(mixture_rate(0.5) * float(q[1]), 4), # 2 times 0.6, the same.
187
- "rate_between_tokens": round(float(u_star[1, 2]), 6), # Zero, the no-revision mask path.
188
- "generates_the_path": bool(torch.allclose(u_star @ p, p_dot, atol=1e-9)),
189
- }
190
-
191
- # The same check with the letter frequency of the synthetic DNA as the data marginal.
192
- splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
193
- counts = torch.bincount(splits["train_tokens"].flatten(), minlength=DNA_SIZE).double()
194
- frequency = counts / counts.sum() # [4] the GC-rich background the lecture shares.
195
- p_dna, p_dot_dna = mask_path(frequency, kappa_t=0.5)
196
- u_dna = kinetic_optimal(p_dna, p_dot_dna)
197
- report["dna_marginal"] = [round(float(v), 4) for v in frequency]
198
- report["dna_rate_mask_to_G"] = round(float(u_dna[2, MASK]), 4) # 2 times the frequency of G.
199
- report["dna_energy_density"] = round(energy_density(u_dna, p_dna), 4) # Expected squared rate.
200
- report["dna_flux_mask_to_G"] = round(float(flux(u_dna, p_dna)[2, MASK]), 4) # Rate times p_t(m).
201
-
202
- # Section 5.16.5: the metric path toward G at t = 0.5.
203
- g, c = DNA.index("G"), DNA.index("C")
204
- beta_t, beta_dot = beta_schedule(0.5) # 1 and 4.
205
- p_metric = metric_path(g, beta_t)
206
- u_metric = metric_velocity(g, beta_t, beta_dot)
207
- report["metric_path_at_0.5"] = [round(float(v), 4) for v in p_metric]
208
- report["rate_C_to_A"] = round(float(u_metric[0, c]), 3) # 0.898.
209
- report["rate_C_to_G"] = round(float(u_metric[g, c]), 3) # 4.882.
210
- report["rate_A_to_G"] = round(float(u_metric[g, 0]), 3) # 2.441.
211
- report["rate_out_of_C"] = round(float(-u_metric[c, c]), 3) # 5.780.
212
- step = torch.eye(DNA_SIZE)[c] + 0.05 * u_metric[:, c] # One Euler step of size h = 0.05 from C.
213
- report["euler_step_from_C"] = [round(float(v), 4) for v in step]
214
- report["p_dot_from_velocity"] = [round(float(v), 4) for v in u_metric @ p_metric]
215
- report["p_dot_from_path"] = [round(float(v), 4) for v in path_derivative(g, 0.5)]
216
-
217
- # Section 5.16.7: the energy of the two schedules, and the geodesic schedule itself.
218
- report["energy_linear_1e-6"] = round(schedule_energy(*LINEAR, args.steps, 1e-6), 2) # 27.63.
219
- report["energy_linear_1e-9"] = round(schedule_energy(*LINEAR, args.steps, 1e-9), 2) # 41.45.
220
- report["energy_geodesic"] = round(schedule_energy(*GEODESIC, args.steps, 1e-6), 4) # 9.8696.
221
- report["pi_squared"] = round(math.pi ** 2, 4) # The squared arc length of a quarter circle.
222
- report["geodesic_kappa_0.25"] = round(geodesic_schedule(0.25), 4) # 0.1464.
223
- report["geodesic_kappa_0.75"] = round(geodesic_schedule(0.75), 4) # 0.8536.
224
- report["geodesic_rate_0.9"] = round(math.pi * math.tan(math.pi * 0.9 / 2), 4) # 19.84.
225
- report["uniform_source_kappa_0.5"] = round(geodesic_schedule(0.5, 0.25), 4) # 0.6667.
226
- print_report("kinetic-optimal paths", report)
227
- save_run(args.out, config=vars(args), report=report)
228
-
229
-
230
- if __name__ == "__main__":
231
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_5/multi_objective.py DELETED
@@ -1,241 +0,0 @@
1
- """Several objectives at once: MOG-DFM's cone and pCoMole's rollout.
2
-
3
- A peptide is scored on binding, solubility and half-life together, and no dataset labels one
4
- sequence for all of them, so the single value function of Discrete Guidance is out of reach.
5
- MOG-DFM drops the ratio form and builds a tilt out of a rank inside each objective and a direction
6
- against a weight vector, with a cone that removes the candidates pointing the wrong way. pCoMole
7
- keeps the ratio and replaces the property likelihood by an exponentiated worst-objective utility,
8
- whose value at a candidate is the average terminal reward over rollouts of the base flow.
9
-
10
- Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
11
- u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
12
-
13
- Chapter 5, Sections 5.30 to 5.32. The single-objective tilt is in ``lecture_5/guidance.py``. The
14
- rollout runs on the synthetic DNA of ``cis6270.data``, scored by GC fraction against AT fraction.
15
- Prints the Pareto front, the chapter's MOG-DFM step with its cone, the chapter's pCoMole rollout,
16
- and a rollout on DNA whose value reverses the base rates.
17
-
18
- python lecture_5/multi_objective.py # 600 rollouts per candidate on DNA
19
- python lecture_5/multi_objective.py --steps 4000 # a tighter estimate of the second value
20
- python lecture_5/multi_objective.py --steps 20 --quiet # twenty rollouts, for a first look
21
- """
22
-
23
- # %% 01. Imports and configuration
24
- import math
25
-
26
- import torch
27
-
28
- from cis6270.data import DNA, DNA_SIZE, MOTIF, decode_dna, dna_splits
29
- from cis6270.runner import common_parser, print_report, save_run, set_seed
30
-
31
- MASK = DNA_SIZE # Index 4 is the mask symbol m, which a candidate still holds off its own position.
32
- OMEGA = torch.tensor([0.5, 0.5], dtype=torch.double) # The trade-off the DNA rollout asks for.
33
- REFERENCE = torch.tensor([0.4, 0.3], dtype=torch.double) # The levels r_k the objectives sit above.
34
- BETA = 2.0 # The inverse temperature of the terminal reward.
35
-
36
-
37
- # %% 02. Several objectives at once
38
- #
39
- # With several objectives there is no single best sequence. A candidate is on the Pareto front when
40
- # no other candidate matches it everywhere and beats it somewhere, and a weight vector picks one
41
- # point of that front by collapsing the scores to a single weighted number.
42
-
43
-
44
- def dominated(scores, row):
45
- """True when some other candidate is at least as good everywhere and better somewhere."""
46
- others = torch.cat([scores[:row], scores[row + 1:]]) # Every candidate except this one.
47
- weak = (others >= scores[row]).all(dim=1) # At least as good on every objective.
48
- strict = (others > scores[row]).any(dim=1) # Strictly better on at least one.
49
- return bool((weak & strict).any())
50
-
51
-
52
- def pareto_front(scores):
53
- """The indices of the candidates no other candidate dominates. scores: [M, N] -> list."""
54
- return [row for row in range(len(scores)) if not dominated(scores, row)]
55
-
56
-
57
- # %% 03. MOG-DFM: rank, direction and a cone
58
- #
59
- # Without a joint likelihood there is no ratio to compute, so every candidate letter is scored twice.
60
- # Ranking inside each objective removes the units, the inner product with the weight vector measures
61
- # direction, and the two z-scored terms exponentiate into a tilt on the pretrained rate.
62
-
63
-
64
- def rank_scores(changes):
65
- """I_n(y^i, x), the rank of each candidate's change within each objective, over the alphabet size."""
66
- order = changes.argsort(dim=0).argsort(dim=0) + 1 # Rank one for the smallest change.
67
- return order.to(changes.dtype) / changes.shape[0] # [K, N] in {1/K, ..., 1}.
68
-
69
-
70
- def z_score(values):
71
- """Norm[.], the z-score across the candidate letters at one position."""
72
- spread = values.std(unbiased=False).clamp(min=1e-12) # The population deviation, held off zero.
73
- return (values - values.mean()) / spread # Zero everywhere when no candidate stands out.
74
-
75
-
76
- def combined_score(changes, importance, weights, lam=1.0):
77
- """Delta S = Norm[mean_n i_n I_n] + lambda Norm[Delta s . omega]. changes: [K, N] -> [K]."""
78
- ranks = (rank_scores(changes) * importance).mean(dim=1) # Importance-weighted average of ranks.
79
- direction = changes @ weights # [K] the directional score of each candidate.
80
- return z_score(ranks) + lam * z_score(direction), ranks, direction
81
-
82
-
83
- # %% 04. Accepting a move only inside a cone
84
- #
85
- # A z-scored score puts about half the candidates above zero, so the tilt alone promotes the least
86
- # bad letter at a position where every letter moves away from the requested trade-off. The angle
87
- # between a candidate's score change and omega removes those, and a controller on the logarithm of
88
- # the angle holds the surviving fraction near one minus the target rejection rate.
89
-
90
-
91
- def cone_angles(changes, weights):
92
- """alpha^i = arccos(Delta s . omega / (||Delta s|| ||omega||)), in degrees."""
93
- cosine = (changes @ weights) / (changes.norm(dim=1) * weights.norm()) # [K].
94
- return torch.rad2deg(torch.arccos(cosine.clamp(-1.0, 1.0))), cosine
95
-
96
-
97
- def update_cone(phi, rejected, target=0.2, gain=0.5, low=30.0, high=90.0):
98
- """Phi_{t+h} = clip(Phi_t exp(eta (r - tau)), Phi_min, Phi_max), proportional control on log Phi."""
99
- return min(max(phi * math.exp(gain * (rejected - target)), low), high) # Wider when it rejects more.
100
-
101
-
102
- # %% 05. pCoMole: a reward in place of a likelihood
103
- #
104
- # Scoring a sequence by its weakest objective stops the sampler from trading one property away, and a
105
- # small multiple of the sum breaks ties. The exponentiated utility times a feasibility indicator is
106
- # the terminal reward, and the value of a candidate is the average reward over rollouts of the base
107
- # flow started from it.
108
-
109
-
110
- def utility(scores, weights, reference, rho=0.1):
111
- """U(x) = min_k omega_k (f_k(x) - r_k) + rho sum_k omega_k (f_k(x) - r_k)."""
112
- gaps = weights * (scores - reference) # [N] the weighted gap on each objective.
113
- return float(gaps.min() + rho * gaps.sum()) # The minimum, with the tie-breaking correction.
114
-
115
-
116
- def terminal_reward(scores, weights, reference, beta, feasible, rho=0.1):
117
- """G(x) = exp(beta U(x)) 1[x in F], which is zero for an infeasible sequence."""
118
- return math.exp(beta * utility(scores, weights, reference, rho)) * float(feasible)
119
-
120
-
121
- def rollout_value(rollouts, feasible, weights, reference, beta, rho=0.1):
122
- """hat h_t(y_c), the average terminal reward over R continuations of the base flow."""
123
- rewards = [terminal_reward(s, weights, reference, beta, f, rho) for s, f in zip(rollouts, feasible)]
124
- return sum(rewards) / len(rewards), rewards # A candidate with no feasible ending scores zero.
125
-
126
-
127
- # %% 06. A rollout on the synthetic DNA
128
- #
129
- # The base flow of a rollout is the mask-source sampler of this chapter with the letter frequency of
130
- # the data as its denoiser, so a continuation costs no network call. Two objectives that pull
131
- # against each other are the GC fraction and the AT fraction of the finished sequence, and the
132
- # feasible set is the sequences that carry the planted motif.
133
-
134
-
135
- def continuations(partial, frequency, rollouts, generator=None):
136
- """Fill every masked position of a candidate from the base flow. partial: [L] -> [R, L]."""
137
- grid = partial[None, :].repeat(rollouts, 1) # [R, L] one copy of the candidate per rollout.
138
- masked = grid == MASK # [R, L] the positions the flow still owes a letter.
139
- draws = torch.multinomial(frequency, int(masked.sum()), replacement=True, generator=generator)
140
- return grid.masked_scatter(masked, draws) # [R, L] the terminal sequences of the rollouts.
141
-
142
-
143
- def dna_objectives(sequences):
144
- """The GC fraction and the AT fraction of each sequence, which sum to one. [R, L] -> [R, 2]."""
145
- gc = ((sequences == 1) | (sequences == 2)).to(torch.get_default_dtype()).mean(dim=1) # C and G.
146
- return torch.stack([gc, 1.0 - gc], dim=1) # [R, 2] raising one lowers the other.
147
-
148
-
149
- def carries_motif(sequences):
150
- """One per sequence that holds the planted motif, which is the hard constraint here. [R] ints."""
151
- return [int(MOTIF in sequence) for sequence in decode_dna(sequences)]
152
-
153
-
154
- def dna_rollout_value(partial, frequency, rollouts, weights, reference, beta, generator=None):
155
- """hat h_t at one candidate, from ``rollouts`` continuations of the base flow. Returns a float."""
156
- finished = continuations(partial, frequency, rollouts, generator) # [R, L].
157
- scores = dna_objectives(finished) # [R, 2] the two objectives at the end of each rollout.
158
- value, _ = rollout_value(list(scores), carries_motif(finished), weights, reference, beta)
159
- return value # Zero when no continuation of this candidate is feasible.
160
-
161
-
162
- # %% 07. The report
163
- def main():
164
- parser = common_parser(__doc__.split("\n")[0])
165
- args = parser.parse_args()
166
- set_seed(args.seed)
167
-
168
- # Section 5.30: four candidates on two objectives, and the two weight vectors.
169
- candidates = torch.tensor([[0.8, 0.3], [0.6, 0.7], [0.5, 0.2], [0.6, 0.3]], dtype=torch.double)
170
- front = pareto_front(candidates)
171
- report = {
172
- "pareto_front": front, # Candidates 1 and 2; 3 and 4 are dominated.
173
- "weighted_at_0.6_0.4": [round(float(s), 3) for s in candidates @ torch.tensor(
174
- [0.6, 0.4], dtype=torch.double)], # 0.60 for the first and 0.64 for the second.
175
- "weighted_at_0.8_0.2": [round(float(s), 3) for s in candidates @ torch.tensor(
176
- [0.8, 0.2], dtype=torch.double)], # 0.70 and 0.62, which reverses the order.
177
- }
178
-
179
- # Section 5.31.2: one MOG-DFM step at a position holding A, with three candidate letters.
180
- changes = torch.tensor([[0.0, 0.0], [0.20, -0.05], [0.05, 0.15], [-0.10, 0.08]], dtype=torch.double)
181
- importance = torch.tensor([1.2, 0.8], dtype=torch.double)
182
- weights = torch.tensor([0.6, 0.4], dtype=torch.double)
183
- delta_s, ranks, direction = combined_score(changes, importance, weights)
184
- report["rank_average"] = [round(float(r), 3) for r in ranks] # (0.500, 0.700, 0.850, 0.450).
185
- report["directional_scores"] = [round(float(d), 3) for d in direction] # (0, 0.100, 0.090, -0.028).
186
- report["combined_score"] = [round(float(s), 3) for s in delta_s] # (-1.511, 1.541, 2.297, -2.327).
187
- posterior = torch.tensor([0.3, 0.3, 0.3, 0.1], dtype=torch.double) # The denoiser at t = 0.7.
188
- base = posterior[1:] / (1.0 - 0.7) # The pretrained rates out of A toward C, G and T.
189
- report["base_rates"] = [round(float(r), 4) for r in base] # (1.000, 1.000, 0.333).
190
- tilted = base * delta_s[1:].exp()
191
- report["guided_rates"] = [round(float(r), 3) for r in tilted] # (4.667, 9.948, 0.033).
192
- angles, cosines = cone_angles(changes[1:], weights)
193
- report["cone_cosines"] = [round(float(c), 4) for c in cosines] # (0.6727, 0.7894, -0.3032).
194
- report["cone_angles_deg"] = [round(float(a), 1) for a in angles] # (47.7, 37.9, 107.7).
195
- inside = angles <= 60.0 # The cone test at Phi = 60 degrees.
196
- survivors = tilted[inside]
197
- report["after_cone"] = [round(float(r), 3) for r in survivors / survivors.sum()] # (0.319, 0.681).
198
- rejected = float((~inside).to(torch.get_default_dtype()).mean()) # One candidate of three.
199
- report["next_cone_angle"] = round(update_cone(60.0, rejected), 1) # 64.1.
200
-
201
- # Section 5.32.2: three candidate edits, each rolled out four times.
202
- omega, reference = torch.tensor([0.5, 0.5], dtype=torch.double), torch.zeros(2, dtype=torch.double)
203
- edits = [
204
- ([(0.8, 0.4), (0.6, 0.6), (0.9, 0.1), (0.5, 0.5)], [1, 1, 0, 1], 0.6),
205
- ([(1.0, 0.0), (0.9, 0.1), (0.95, 0.05), (1.0, 0.1)], [1, 1, 1, 1], 1.2),
206
- ([(0.4, 0.4)] * 4, [0, 0, 0, 0], 2.0),
207
- ]
208
- values = [rollout_value([torch.tensor(s, dtype=torch.double) for s in rollouts],
209
- feasible, omega, reference, 2.0)[0] for rollouts, feasible, _ in edits]
210
- base_rates = torch.tensor([rate for _, _, rate in edits], dtype=torch.double)
211
- products = torch.tensor(values) * base_rates # The base rate times the continuation value.
212
- report["rollout_values"] = [round(v, 3) for v in values] # (1.390, 1.181, 0).
213
- report["rollout_products"] = [round(float(p), 3) for p in products] # (0.834, 1.417, 0).
214
- report["rollout_selection"] = [round(float(p), 3) for p in products / products.sum()]
215
- report["rollout_base_selection"] = [round(float(p), 3) for p in base_rates / base_rates.sum()]
216
-
217
- # The same rollout on the shared DNA, from a partly revealed sequence with ACG already in place.
218
- splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
219
- counts = torch.bincount(splits["train_tokens"].flatten(), minlength=DNA_SIZE).double()
220
- frequency = counts / counts.sum() # [4] the denoiser of the base flow, GC-rich like the data.
221
- # The base rates below are kappa_dot/(1 - kappa) times that denoiser, read at t = 0.3.
222
- revealed = torch.full((args.length,), MASK, dtype=torch.long)
223
- revealed[2:5] = torch.tensor([0, 1, 2]) # Positions 3, 4 and 5 hold A, C and G.
224
- finish = {letter: revealed.clone() for letter in "TC"}
225
- for letter, candidate in finish.items():
226
- candidate[5] = DNA.index(letter) # Position 6 takes T, completing ACGT, or C, which does not.
227
- seen = {letter: dna_rollout_value(candidate, frequency, args.steps, OMEGA, REFERENCE, BETA)
228
- for letter, candidate in finish.items()}
229
- rates = torch.tensor([float(frequency[DNA.index(letter)]) for letter in finish]) / (1.0 - 0.3)
230
- guided = rates * torch.tensor([seen[letter] for letter in finish]) # The base rate times hat h.
231
- report["dna_rollout_value_T"] = round(seen["T"], 4) # Every continuation carries the motif.
232
- report["dna_rollout_value_C"] = round(seen["C"], 4) # Only the ones that plant it elsewhere.
233
- report["dna_base_rates_T_C"] = [round(float(r), 4) for r in rates] # The GC-rich data prefers C.
234
- report["dna_base_selection"] = [round(float(r), 4) for r in rates / rates.sum()]
235
- report["dna_guided_selection"] = [round(float(r), 4) for r in guided / guided.sum()]
236
- print_report("several objectives at once", report)
237
- save_run(args.out, config=vars(args), report=report)
238
-
239
-
240
- if __name__ == "__main__":
241
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_5/rectify.py DELETED
@@ -1,293 +0,0 @@
1
- """Rectification and AReUReDi: changing the coupling so that fewer steps suffice.
2
-
3
- A one-step sampler draws every position from its own marginal, so whatever structure the data has
4
- between positions is lost in that draw. The error it makes is the conditional total correlation of
5
- the coupling, which is a property of how the pairs were drawn and not of the data. Training on the
6
- current pairs, sampling from the model, and keeping the pairs it produces lowers that total
7
- correlation, and the discrete counterpart of the transport-cost bound says it never raises it.
8
- AReUReDi then composes a rectified flow with a Metropolis refinement whose proposal starts from the
9
- model's own per-position distribution.
10
-
11
- Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
12
- u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
13
-
14
- Chapter 5, Sections 5.36 to 5.41. The second recoupling runs on adjacent base pairs of the synthetic
15
- DNA of ``cis6270.data``. Prints the total correlation of each round, the Hamming distance and the
16
- one-step success rate, and the AReUReDi proposal with its acceptance ratio.
17
-
18
- python lecture_5/rectify.py # four rounds, 600 quadrature points each
19
- python lecture_5/rectify.py --rounds 6 # closer to the limiting coupling
20
- python lecture_5/rectify.py --steps 20 --quiet # a coarse integration, visibly less accurate
21
- """
22
-
23
- # %% 01. Imports and configuration
24
- import math
25
-
26
- import torch
27
-
28
- from cis6270.data import DNA, DNA_SIZE, dna_splits
29
- from cis6270.runner import common_parser, print_report, save_run, set_seed
30
-
31
-
32
- torch.set_default_dtype(torch.double) # The recoupling is an exact integration, so keep precision.
33
- LENGTH = 2 # Two positions, so the state space has 16 sequences and can be enumerated.
34
- STATES = DNA_SIZE ** LENGTH
35
- LETTER = torch.stack([ # [L, 16] the letter each sequence index holds at each position.
36
- torch.arange(STATES) // DNA_SIZE, torch.arange(STATES) % DNA_SIZE,
37
- ])
38
-
39
-
40
- # %% 02. The pairing of source and data
41
- #
42
- # Since the first Euler step the joint distribution of the next state has been replaced by the
43
- # product of the per-position marginals. With small steps that costs almost nothing. One large step
44
- # draws every position at once from a product, and the divergence between the joint and that product
45
- # is the conditional total correlation of the coupling.
46
-
47
-
48
- def position_marginals(row):
49
- """The per-position marginals of one distribution over sequences. row: [16] -> [L, K]."""
50
- out = torch.zeros(LENGTH, DNA_SIZE)
51
- for i in range(LENGTH):
52
- out[i].index_add_(0, LETTER[i], row) # Collect the mass of every sequence by its letter.
53
- return out
54
-
55
-
56
- def total_correlation(coupling, source):
57
- """TC_pi(X_1 | X_0), the expected divergence from the joint to the product of its marginals."""
58
- total = 0.0
59
- for s in range(STATES):
60
- marginal = position_marginals(coupling[s]) # [L, K] given this source sequence.
61
- product = marginal[0][LETTER[0]] * marginal[1][LETTER[1]] # [16] the factorized draw.
62
- live = coupling[s] > 0
63
- total += float(source[s] * (coupling[s][live] * (coupling[s][live] / product[live]).log()).sum())
64
- return total # Zero exactly when the positions are independent given the source.
65
-
66
-
67
- def one_step_success(coupling, source, support):
68
- """The chance a single factorized draw lands inside the data, averaged over source sequences."""
69
- total = 0.0
70
- for s in range(STATES):
71
- marginal = position_marginals(coupling[s])
72
- total += float(source[s] * sum(marginal[0][a] * marginal[1][b] for a, b in support))
73
- return total # 1/4 under the independent coupling on the complementary pairs.
74
-
75
-
76
- def hamming(coupling, source):
77
- """The expected number of positions at which a source and its paired data sequence differ."""
78
- distance = (LETTER[:, :, None] != LETTER[:, None, :]).sum(dim=0).to(coupling) # [16, 16].
79
- return float((source[:, None] * coupling * distance).sum()) # 1.5 under independent pairing.
80
-
81
-
82
- # %% 03. Total correlation on two bases
83
- #
84
- # Let the data be the four complementary pairs, each with probability one quarter, and the source be
85
- # uniform over all sixteen ordered pairs. Each position of the data alone is uniform over the four
86
- # bases, so the product of the two marginals spreads one sixteenth everywhere while the joint sits on
87
- # four states with one quarter apiece.
88
-
89
-
90
- def complementary_pairs():
91
- """The data distribution over AT, TA, CG and GC, and the indices of those four sequences."""
92
- support = [(DNA.index(a), DNA.index(b)) for a, b in ("AT", "TA", "CG", "GC")]
93
- q = torch.zeros(STATES)
94
- for a, b in support:
95
- q[a * DNA_SIZE + b] = 0.25 # Each complementary pair with probability one quarter.
96
- return q, support
97
-
98
-
99
- def adjacent_pairs(tokens):
100
- """The empirical distribution of adjacent base pairs in the training DNA. Returns [16]."""
101
- left, right = tokens[:, :-1].reshape(-1), tokens[:, 1:].reshape(-1) # Every adjacent pair.
102
- counts = torch.bincount(left * DNA_SIZE + right, minlength=STATES).to(torch.get_default_dtype())
103
- return counts / counts.sum() # [16] over the sixteen ordered pairs.
104
-
105
-
106
- # %% 04. ReDi: one recoupling round
107
- #
108
- # The model here is the entire many-step sampler, and the next coupling pairs a source sample with
109
- # the sequence the sampler produces from it. The velocity is the true marginal velocity of the
110
- # current coupling, and the master equation is integrated without discretization error, so the only
111
- # approximation left is the factorization across the two positions.
112
-
113
-
114
- def generator(joint, t):
115
- """The factorized velocity at time t, in the variable u with t = 1 - exp(-u). Returns [16, 16]."""
116
- eye = torch.eye(DNA_SIZE)
117
- mix = (1 - t) * eye[:, :, None] + t * eye[:, None, :] # [K, K, K] the mixture path at one position.
118
- per = [mix[:, LETTER[i][:, None], LETTER[i][None, :]] for i in range(LENGTH)] # [K, 16, 16] each.
119
- weight = joint[None, None, :, :] * per[0][:, None, :, :] * per[1][None, :, :, :]
120
- weight = weight.reshape(STATES, STATES, STATES) # [x, x_0, x_1] the unnormalized posterior.
121
- norm = weight.sum(dim=(1, 2)).clamp(min=1e-300)
122
- posterior = weight.sum(dim=1) / norm[:, None] # [x, x_1] the posterior over the data sequence.
123
- rates, columns = torch.zeros(STATES, STATES), torch.arange(STATES)
124
- for i in range(LENGTH):
125
- denoiser = torch.zeros(STATES, DNA_SIZE) # [x, a] the denoiser at position i.
126
- denoiser.index_add_(1, LETTER[i], posterior)
127
- for a in range(DNA_SIZE):
128
- moved = LETTER.clone()
129
- moved[i] = a # [L, 16] every sequence with position i replaced by the letter a.
130
- destination = moved[0] * DNA_SIZE + moved[1] # [16] the index of that sequence.
131
- keep = LETTER[i] != a # A sequence already holding a at position i sends nothing.
132
- rates[destination[keep], columns[keep]] += denoiser[keep, a]
133
- return rates - torch.diag(rates.sum(dim=0)) # In u-time the schedule factor is one.
134
-
135
-
136
- def recouple(coupling, source, steps, horizon=14.0):
137
- """Run the exact sampler from every source sequence and return the coupling it produces.
138
-
139
- The substitution t = 1 - exp(-u) holds the schedule factor at one, so the integration stays
140
- accurate as t approaches the horizon, where 1 - t is about 1e-6. Each cell takes a Heun step,
141
- which is second order and reaches the chapter's figures at a few hundred cells.
142
- """
143
- joint = source[:, None] * coupling # pi(x_0, x_1) over pairs.
144
- probability = torch.eye(STATES) # Row s is the state distribution started at the source s.
145
- du = horizon / steps
146
- for k in range(steps):
147
- left = generator(joint, 1.0 - math.exp(-k * du)) # The velocity at the start of the cell.
148
- right = generator(joint, 1.0 - math.exp(-(k + 1) * du)) # And at its end.
149
- first = probability @ left.T # The Euler slope.
150
- second = (probability + du * first) @ right.T # The slope the Euler step lands on.
151
- probability = probability + 0.5 * du * (first + second) # Their average.
152
- probability = probability.clamp(min=0.0)
153
- probability = probability / probability.sum(dim=1, keepdim=True)
154
- return probability # [16, 16] the next coupling, one row per source sequence.
155
-
156
-
157
- # %% 05. AReUReDi: the Tchebycheff score and the tilt
158
- #
159
- # Scoring a sequence by its weakest weighted objective stops the sampler from trading one property
160
- # away. The target is the base model times the exponentiated score, and the refinement anneals the
161
- # strength linearly over its iterations, so early ones explore and late ones commit.
162
-
163
-
164
- def tchebycheff(scores, weights):
165
- """S_omega(x) = min_n omega_n s_n(x), the smallest weighted objective. scores: [M, N] -> [M]."""
166
- return (scores * weights).min(dim=-1).values # Raising a weight lowers the pressure on it.
167
-
168
-
169
- def anneal(iteration, total, low=1.0, high=20.0):
170
- """eta_t = eta_min + (eta_max - eta_min) t/(T - 1), which is 1 + t at the chapter's settings."""
171
- return low + (high - low) * iteration / (total - 1) # 12 at the twelfth iteration of twenty.
172
-
173
-
174
- # %% 06. The proposal and its acceptance step
175
- #
176
- # The proposal starts from the model's own per-position distribution and reweights it by a balancing
177
- # function of how much the tilt would change. The identity g(u) = u g(1/u) collapses the acceptance
178
- # ratio to a ratio of two one-position normalizers, which costs one sum over the alphabet.
179
-
180
-
181
- def barker(u):
182
- """g(u) = u/(1 + u), which satisfies g(u) = u g(1/u)."""
183
- return u / (1.0 + u) # The square root is the other common choice.
184
-
185
-
186
- def proposal(model_distribution, tilts, current):
187
- """q_i(. | x) and its normalizer Z(x). tilts: [K] the value of W at each letter."""
188
- ratios = tilts / tilts[current] # r_i(y; x), the tilt at the proposed letter over the current one.
189
- weighted = model_distribution * barker(ratios) # The model's prior times the balancing function.
190
- return weighted / weighted.sum(), float(weighted.sum()), ratios # q, Z(x) and the ratios.
191
-
192
-
193
- def acceptance(z_current, z_proposed):
194
- """alpha_i(x, x') = min(1, Z(x)/Z(x')), what the balancing identity leaves of the ratio."""
195
- return min(1.0, z_current / z_proposed) # One in both directions when the normalizers agree.
196
-
197
-
198
- # %% 07. Concentration on the maximizers
199
- #
200
- # Keeping only the maximizers in the denominator and bounding the numerator by the score gap gives an
201
- # exponential bound on the probability of anything outside the maximizing set, so the samples
202
- # concentrate there as the strength grows.
203
-
204
-
205
- def tilted_distribution(base, scores, eta):
206
- """pi_{eta, omega}(x) proportional to p_1(x) exp(eta S_omega(x))."""
207
- weighted = base * (eta * scores).exp()
208
- return weighted / weighted.sum() # [M] over the sequences under consideration.
209
-
210
-
211
- def concentration_bound(base, scores, eta):
212
- """exp(-eta Delta) p_1(x) over the base mass of the maximizers, for a sequence outside the set."""
213
- best = scores.max()
214
- gap = float(best - scores[scores < best].max()) # Delta_omega, the gap to the next best score.
215
- return math.exp(-eta * gap) / float(base[scores == best].sum()), gap
216
-
217
-
218
- # %% 08. The report
219
- def main():
220
- parser = common_parser(__doc__.split("\n")[0])
221
- parser.add_argument("--rounds", type=int, default=4, help="rectification rounds")
222
- args = parser.parse_args()
223
- set_seed(args.seed)
224
-
225
- source = torch.full((STATES,), 1.0 / STATES) # Uniform over all sixteen ordered pairs.
226
- q, support = complementary_pairs()
227
- coupling = q[None, :].repeat(STATES, 1) # The independent coupling pairs every source with q.
228
- report = {
229
- "tc_round_0": round(total_correlation(coupling, source), 3), # log 4 = 1.386.
230
- "log_4": round(math.log(4), 3), # What the enumeration of the four pairs gives.
231
- "hamming_round_0": round(hamming(coupling, source), 3), # 1.5.
232
- "success_round_0": round(one_step_success(coupling, source, support), 4), # 0.25.
233
- }
234
- for round_index in range(1, args.rounds + 1):
235
- coupling = recouple(coupling, source, args.steps) # One application of the operator.
236
- report[f"tc_round_{round_index}"] = round(total_correlation(coupling, source), 3)
237
- report[f"hamming_round_{round_index}"] = round(hamming(coupling, source), 3)
238
- report[f"success_round_{round_index}"] = round(one_step_success(coupling, source, support), 4)
239
- if round_index == 1: # Where the first round's gain comes from, read at two sources.
240
- columns = [a * DNA_SIZE + b for a, b in support] # The four complementary pairs.
241
- for name in ("AA", "AT"): # One source outside the data and one inside it.
242
- row = DNA.index(name[0]) * DNA_SIZE + DNA.index(name[1])
243
- report[f"pairing_from_{name}"] = [round(float(coupling[row, c]), 3) for c in columns]
244
- report["tc_limit"] = round(0.75 * math.log(2), 3) # Twelve sources split in half, 0.520 nats.
245
- report["success_limit"] = round(10.0 / 16.0, 4) # 0.625, four exact sources and twelve halves.
246
- report["hamming_limit"] = round(12.0 / 16.0, 4) # 0.75, the least any coupling can reach.
247
-
248
- # The same measurement on adjacent base pairs of the synthetic DNA, under independent pairing.
249
- splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
250
- dna = adjacent_pairs(splits["train_tokens"]) # [16] the empirical distribution of a base pair.
251
- dna_coupling = dna[None, :].repeat(STATES, 1) # Every source paired with the same distribution.
252
- live = dna[dna > 0]
253
- report["dna_pair_entropy"] = round(float(-(live * live.log()).sum()), 4) # At most log 16 = 2.77.
254
- report["dna_total_correlation"] = round(total_correlation(dna_coupling, source), 4)
255
- report["dna_fraction_of_entropy"] = round(
256
- total_correlation(dna_coupling, source) / float(-(live * live.log()).sum()), 4)
257
-
258
- # Section 5.41.2: one refinement iteration at the twelfth of twenty, with two objectives.
259
- scores = torch.tensor([[0.60, 0.80], [0.70, 0.76], [0.50, 0.90], [0.64, 0.62]])
260
- weights = torch.tensor([0.5, 0.5])
261
- eta = anneal(11, 20) # The twelfth iteration, counting from zero, where 1 + t equals 12.
262
- tchebycheff_scores = tchebycheff(scores, weights)
263
- tilts = (eta * tchebycheff_scores).exp() # W = exp(eta S) at that strength.
264
- report["annealed_eta"] = round(eta, 4) # 1 + t over twenty iterations.
265
- report["tchebycheff"] = [round(float(s), 3) for s in tchebycheff_scores] # (0.300, ...).
266
- report["tilts"] = [round(float(w), 2) for w in tilts] # (36.60, 66.69, 20.09, 41.26).
267
- model_distribution = torch.tensor([0.40, 0.25, 0.20, 0.15]) # The rectified model at this position.
268
- q_current, z_current, ratios = proposal(model_distribution, tilts, 0) # The position holds A.
269
- report["ratios_from_A"] = [round(float(r), 3) for r in ratios] # (1.000, 1.822, 0.549, 1.127).
270
- report["barker_from_A"] = [round(float(barker(r)), 3) for r in ratios] # (0.500, 0.646, ...).
271
- report["normalizer_at_A"] = round(z_current, 4) # 0.5118.
272
- report["proposal_at_A"] = [round(float(p), 3) for p in q_current] # (0.391, 0.315, 0.138, 0.155).
273
- _, z_proposed, _ = proposal(model_distribution, tilts, 1) # The proposed sequence holds C.
274
- report["normalizer_at_C"] = round(z_proposed, 4) # 0.3704.
275
- report["accept_A_to_C"] = round(acceptance(z_current, z_proposed), 3) # One.
276
- report["accept_C_to_A"] = round(acceptance(z_proposed, z_current), 3) # 0.724.
277
-
278
- # Section 5.41.3: the tilted distribution over four sequences, and the bound on a non-maximizer.
279
- base = torch.tensor([0.50, 0.25, 0.15, 0.10])
280
- objective = torch.tensor([0.20, 0.30, 0.45, 0.45])
281
- for strength in (20.0, 60.0):
282
- tilted = tilted_distribution(base, objective, strength)
283
- bound, gap = concentration_bound(base, objective, strength)
284
- report[f"mass_on_maximizers_eta_{strength:g}"] = f"{float(tilted[2:].sum()):.6f}"
285
- report[f"second_sequence_eta_{strength:g}"] = f"{float(tilted[1]):.6f}"
286
- report[f"bound_eta_{strength:g}"] = f"{bound * float(base[1]):.6f}"
287
- report["score_gap"] = round(gap, 4) # 0.15.
288
- print_report("rectification", report)
289
- save_run(args.out, config=vars(args), report=report)
290
-
291
-
292
- if __name__ == "__main__":
293
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_5/velocity.py DELETED
@@ -1,212 +0,0 @@
1
- """A jump rate written as a velocity, and the master equation it satisfies.
2
-
3
- Nothing here is learned. A velocity on a finite set is a rate matrix read from the destination's
4
- side, two rules fix which tables of numbers qualify, and the master equation says when such a table
5
- generates a probability path. The schedule factor of the mixture path turns out to be a hazard rate
6
- whose survival curve is the path itself, and the discrete velocity has the same shape as the
7
- continuous one of Chapter 2, with a posterior distribution in place of a posterior mean.
8
-
9
- Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
10
- u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
11
-
12
- Chapter 5, Sections 5.2, 5.3, 5.6.3 and 5.8.1. The starting distribution of the long integration is
13
- the letter frequency of the synthetic DNA of ``cis6270.data``. Prints the chapter's worked velocity,
14
- its Euler step, the hazard at two times, and the two velocities side by side.
15
-
16
- python lecture_5/velocity.py # 600 Euler steps in the long integration
17
- python lecture_5/velocity.py --steps 20 --quiet # a coarser grid, visibly less accurate
18
- """
19
-
20
- # %% 01. Imports and configuration
21
- import math
22
-
23
- import torch
24
- import torch.nn.functional as F
25
-
26
- from cis6270.data import DNA, DNA_SIZE, dna_splits
27
- from cis6270.runner import common_parser, print_report, save_run, set_seed
28
-
29
- torch.set_default_dtype(torch.double) # Every number here is arithmetic, so keep full precision.
30
-
31
-
32
-
33
- # %% 02. A jump rate written as a velocity
34
- #
35
- # Chapter 4 wrote R_t(x, y) for the rate of jumping from x to y. Discrete flow matching writes the
36
- # same number with the destination first, u_t(y, x) = R_t(x, y). Fixing x and letting y vary gives
37
- # one vector attached to the state x, with one component per destination.
38
-
39
-
40
- def velocity_from_rates(rates):
41
- """Transpose a Chapter 4 rate matrix R_t(x, y) into a Chapter 5 velocity u_t(y, x)."""
42
- return rates.transpose(0, 1) # [K, K] with the destination now indexing the row.
43
-
44
-
45
- def jump_to_one_state(target, size=DNA_SIZE, rate=1.0):
46
- """Every state other than ``target`` leaves toward it at ``rate``, and ``target`` never leaves."""
47
- u = torch.zeros(size, size) # [K, K], row = destination, column = current state.
48
- for x in range(size):
49
- if x == target:
50
- continue
51
- u[target, x] = rate # The one positive off-diagonal entry in column x.
52
- u[x, x] = -rate # The diagonal is minus the total rate of leaving x.
53
- return u
54
-
55
-
56
- # %% 03. The two rules a velocity obeys
57
- #
58
- # Off the diagonal the entries are nonnegative, since probability cannot flow out of a state at a
59
- # negative rate, and every column sums to zero, since probability leaving one state has to arrive at
60
- # another. The two rules fix the diagonal once the off-diagonal entries are chosen.
61
-
62
-
63
- def obeys_the_two_rules(u, tolerance=1e-9):
64
- """True when every off-diagonal entry is nonnegative and every column sums to zero."""
65
- off = u - torch.diag(torch.diagonal(u)) # The matrix with the diagonal removed.
66
- return bool((off >= -tolerance).all() and (u.sum(dim=0).abs() < tolerance).all())
67
-
68
-
69
- def set_diagonal(u):
70
- """u_t(x, x) = -sum_{y != x} u_t(y, x), the total rate of leaving x."""
71
- u = u.clone()
72
- u.fill_diagonal_(0.0) # Keep the off-diagonal entries and discard whatever the diagonal held.
73
- return u - torch.diag(u.sum(dim=0)) # Each column now sums to zero.
74
-
75
-
76
- # %% 04. The master equation in velocity form
77
- #
78
- # The rate of change of p_t(y) is the velocity into y times the probability of each current state,
79
- # summed over states. Written as a matrix equation it is p_dot = u p, with p a column vector and no
80
- # transpose, which is the column convention of this chapter.
81
-
82
-
83
- def master_equation(u, p):
84
- """p_dot_t(y) = sum_x u_t(y, x) p_t(x). u: [K, K], p: [K] -> [K]."""
85
- return u @ p # The matrix multiplies the probability vector from the left.
86
-
87
-
88
- def arrivals_and_departures(u, p, y):
89
- """The flux arriving at y from every other state, and the flux leaving y, as two numbers."""
90
- others = [x for x in range(len(p)) if x != y] # Every state except y itself.
91
- arriving = sum(float(u[y, x] * p[x]) for x in others) # u_t(y, x) p_t(x) along each edge in.
92
- leaving = sum(float(u[x, y] * p[y]) for x in others) # u_t(x, y) p_t(y) along each edge out.
93
- return arriving, leaving # Their difference is p_dot_t(y).
94
-
95
-
96
- def integrate(u, p, steps):
97
- """Carry p forward by ``steps`` Euler steps over one unit of time under a constant velocity."""
98
- h = 1.0 / steps # The time increment of one step.
99
- for _ in range(steps):
100
- p = p + h * master_equation(u, p) # p_{t+h} = p_t + h p_dot_t.
101
- return p # [K] after one unit of time.
102
-
103
-
104
- # %% 05. One velocity on DNA, with numbers
105
- #
106
- # Let the token be a single DNA base, let every base other than G jump to G at rate 1, and let G
107
- # never leave. Probability moves into G and the total stays at one, which the two rules guarantee.
108
-
109
-
110
- def worked_master_equation():
111
- """Section 5.3.2 with p_t = (0.1, 0.2, 0.3, 0.4) and the all-to-G velocity."""
112
- u = jump_to_one_state(DNA.index("G")) # [4, 4] the matrix printed in the chapter.
113
- p = torch.tensor([0.1, 0.2, 0.3, 0.4]) # [4] over A, C, G, T.
114
- p_dot = master_equation(u, p) # (-0.1, -0.2, 0.7, -0.4).
115
- arriving, leaving = arrivals_and_departures(u, p, DNA.index("G")) # 0.7 in and 0 out.
116
- return u, p, p_dot, arriving, leaving
117
-
118
-
119
- # %% 06. Survival and hazard
120
- #
121
- # The schedule factor kappa_dot_t/(1 - kappa_t) is a hazard rate, and the probability that a position
122
- # still holds its source token is its survival curve. Separating variables gives S(t) = 1 - kappa_t,
123
- # the same weight the mixture path puts on the source token.
124
-
125
-
126
- def hazard(t):
127
- """kappa_dot_t/(1 - kappa_t) on the linear schedule, which is 1/(1 - t)."""
128
- return 1.0 / (1.0 - t) # 1 at t = 0, 2 at t = 0.5, 10 at t = 0.9.
129
-
130
-
131
- def survival(steps, upper=0.9):
132
- """Integrate dS/dt = -hazard(t) S(t) from S(0) = 1 and return S at ``upper``."""
133
- h, s = upper / steps, 1.0 # One increment and the initial survival probability.
134
- for k in range(steps):
135
- s = s - h * hazard(k * h) * s # Euler on the survival equation.
136
- return s # Exactly 1 - kappa_t = 1 - upper, since the two factors telescope at every grid.
137
-
138
-
139
- # %% 07. The continuous and discrete velocities side by side
140
- #
141
- # In continuous space the velocity is the schedule factor times the posterior mean minus the current
142
- # point. On tokens it is the same factor times the posterior distribution minus the point mass on the
143
- # current token. One step of size h displaces the point by h u_t(x); on tokens it redraws from the
144
- # denoiser with probability lambda_t, and the two agree in expectation on the one-hot embedding.
145
-
146
-
147
- def continuous_velocity(x, x1_hat, t):
148
- """u_t(x) = kappa_dot/(1 - kappa) [x_hat_{1|t}(x) - x] at a point of R^d. x: [d] -> [d]."""
149
- return hazard(t) * (x1_hat - x) # The displacement that remains, divided by the time remaining.
150
-
151
-
152
- def discrete_velocity(x_i, p1, t, size=DNA_SIZE):
153
- """u^i_t(., x) = kappa_dot/(1 - kappa) [p_{1|t}(. | x) - delta_{x^i}]. Returns [K]."""
154
- return hazard(t) * (p1 - F.one_hot(torch.tensor(x_i), size).double()) # Signed, sums to zero.
155
-
156
-
157
- def side_by_side(x_i, p1, t, h):
158
- """The continuous Euler step at the one-hot point and the discrete redraw distribution."""
159
- x = F.one_hot(torch.tensor(x_i), len(p1)).double() # The current token as a corner of R^K.
160
- moved = x + h * continuous_velocity(x, p1, t) # Displace by h times the velocity.
161
- lam = h * hazard(t) # lambda_t, the redraw probability of the two-part rule.
162
- drawn = (1.0 - lam) * x + lam * p1 # Keep the current token, or redraw from the denoiser.
163
- return moved, drawn, lam # The two coincide, which is the last row of Table 5.1.
164
-
165
-
166
- # %% 08. The report
167
- def main():
168
- parser = common_parser(__doc__.split("\n")[0])
169
- args = parser.parse_args()
170
- set_seed(args.seed)
171
-
172
- u, p, p_dot, arriving, leaving = worked_master_equation()
173
- report = {
174
- "two_rules_hold": obeys_the_two_rules(u), # Nonnegative off the diagonal, columns sum to zero.
175
- "diagonal_recovered": bool(torch.allclose(u, set_diagonal(u))), # The rules fix the diagonal.
176
- "p_dot": [round(float(v), 4) for v in p_dot], # (-0.1, -0.2, 0.7, -0.4).
177
- "p_dot_sums_to": round(float(p_dot.sum()), 6), # Zero, so probability is conserved.
178
- "flux_into_G": round(arriving, 4), # 0.7, the three incoming rates against their probabilities.
179
- "flux_out_of_G": round(leaving, 4), # 0, since G has no outgoing rate.
180
- "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).
181
- "hazard_at_0.5": round(hazard(0.5), 4), # 2.
182
- "hazard_at_0.9": round(hazard(0.9), 4), # 10.
183
- "survival_at_0.9": f"{survival(args.steps):.6f}", # 1 - 0.9 = 0.1 at any grid.
184
- "survival_exact": 0.1, # S(t) = 1 - kappa_t.
185
- }
186
-
187
- # Section 5.8.1: one position holding G with the denoiser of the chapter.
188
- p1 = torch.tensor([0.1, 0.6, 0.2, 0.1]) # [4] over A, C, G, T.
189
- moved, drawn, lam = side_by_side(DNA.index("G"), p1, t=0.5, h=0.1)
190
- report["discrete_velocity_at_G"] = [round(float(v), 4) for v in discrete_velocity(2, p1, 0.5)]
191
- report["continuous_step"] = [round(float(v), 4) for v in moved] # (0.02, 0.12, 0.84, 0.02).
192
- report["discrete_redraw"] = [round(float(v), 4) for v in drawn] # The identical vector.
193
- report["redraw_probability"] = round(lam, 4) # lambda_t = 0.2.
194
-
195
- # The same velocity run for a whole unit of time, starting from the data's letter frequency.
196
- splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
197
- counts = torch.bincount(splits["train_tokens"].flatten(), minlength=DNA_SIZE).double()
198
- start = counts / counts.sum() # [4] the empirical frequency of A, C, G, T in the training DNA.
199
- final = integrate(u, start, args.steps) # Euler on the master equation, args.steps increments.
200
- exact = start.clone()
201
- exact[:] = start * math.exp(-1.0) # Every base other than G decays as exp(-t).
202
- exact[DNA.index("G")] = 1.0 - (start.sum() - start[DNA.index("G")]) * math.exp(-1.0)
203
- report["dna_start"] = [round(float(v), 4) for v in start] # The GC-rich background of the data.
204
- report["dna_after_one_unit"] = [round(float(v), 4) for v in final] # Euler at args.steps.
205
- report["dna_exact"] = [round(float(v), 4) for v in exact] # The closed-form solution.
206
- report["euler_error"] = round(float((final - exact).abs().max()), 6) # Shrinks with args.steps.
207
- print_report("a jump rate written as a velocity", report)
208
- save_run(args.out, config=vars(args), report=report)
209
-
210
-
211
- if __name__ == "__main__":
212
- main()