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

Delete lecture_6

Browse files
lecture_6/README.md DELETED
@@ -1,150 +0,0 @@
1
- # Lecture 6: Flow Maps
2
-
3
- Chapter 6 of the notes, `book/chapters/ch6_flow_maps.tex`.
4
-
5
- Chapters 2 to 5 learned a local change and applied it again and again, so the number of steps set
6
- the cost of a sample. This lecture learns the map between two times directly. The map
7
- `X_{s,t}(x)` is fixed by three axioms, identity, cocycle and tangent, and every file writes it the
8
- same way, as the state plus the elapsed time times an average velocity the network produces, which
9
- makes the identity axiom hold for any weights at all. Differentiating the cocycle in the end time
10
- gives the Lagrangian identity, in the start time the Eulerian identity, and leaving it finite
11
- gives the composition identity. Each one becomes a squared residual, each is paired with a flow
12
- matching anchor on the diagonal, and the rest of the lecture carries the construction into five
13
- settings: a point in space, a distribution over tokens, a Brownian path, a dimension that grows,
14
- and a conditioning state.
15
-
16
- Every file is standalone, runs offline on a laptop CPU, and prints the numbers the chapter works
17
- by hand.
18
-
19
- ## The files
20
-
21
- | File | Command | What it builds |
22
- | --- | --- | --- |
23
- | `flow_map.py` | `python lecture_6/flow_map.py` | No learning. The chapter's velocity, the map by RK4, the three axioms, the inverse, the Jacobian and its differential equation, Liouville's formula, density transport and the generator |
24
- | `residuals.py` | `python lecture_6/residuals.py` | The Lagrangian, Eulerian and composition residuals and the diagonal anchor, from the chapter's listing, with four models trained on the four Gaussian blobs |
25
- | `meanflow.py` | `python lecture_6/meanflow.py` | MeanFlow: the Eulerian identity in the average-velocity parameterization, with the total derivative from one forward-mode pass |
26
- | `shortcut.py` | `python lecture_6/shortcut.py` | Shortcut models: the composition identity indexed by step size, on a dyadic ladder from 1/128 to 1 |
27
- | `consistency.py` | `python lecture_6/consistency.py` | Consistency models on the edge `t = 1` with the boundary coefficients, and consistency trajectory models on the whole triangle |
28
- | `fmlm.py` | `python lecture_6/fmlm.py` | Flow Map Language Models: the two-time denoiser, the convex form, the cocycle in denoiser coordinates, the two gradients a softmax head sees, two KL terms and the decoding clock, on DNA |
29
- | `categorical_map.py` | `python lecture_6/categorical_map.py` | Categorical Flow Maps: the Lagrangian identity with a softmax head, the teacher gap and drift, and the bound that replaces the residual, on phrases |
30
- | `discrete_map.py` | `python lecture_6/discrete_map.py` | Discrete Flow Maps: why the derivative targets leave the simplex, and the repair in logit space, on DNA |
31
- | `stochastic_map.py` | `python lecture_6/stochastic_map.py` | Strong stochastic flow maps: the solution map of an SDE, the weak map's floor, the Legendre coefficients and the Chen relations |
32
- | `expanding.py` | `python lecture_6/expanding.py` | Expanding flow maps: a state whose length grows, local clocks, the composition identity under growth, and one draw of the insertion noise |
33
- | `posterior_map.py` | `python lecture_6/posterior_map.py` | Diamond Maps and Meta Flow Maps: the inner clock, posterior samples, the value function from K samples, its gradient and the importance weight |
34
-
35
- Every file takes the shared flags of `cis6270.runner`. `--steps 20 --quiet` finishes in a few
36
- seconds. In `flow_map.py`, `stochastic_map.py` and `posterior_map.py` nothing is trained, so
37
- `--steps` sets the Runge-Kutta substep count, the Monte Carlo draw count and the inner substep
38
- count instead, which each file's docstring says.
39
-
40
- ## What a default run prints
41
-
42
- The chapter's worked numbers come out exactly, and the test suite asserts them.
43
-
44
- | Quantity | Chapter | Printed |
45
- | --- | --- | --- |
46
- | `X_{0,1}(0.7)`, the cocycle through 0.5, the inverse, `det J_{0,1}` | 4.1, 4.1, 0.7, 3 | 4.100000, 4.100000, 0.700000, 3.000000 |
47
- | `log det J_{0,1}` by Liouville, and the density ratio | 1.0986, 3.000 | 1.0986, 3.0000 |
48
- | Stay-still residuals at `x = 1`, `s = 0`, `t = 1` | 1, 1, 0 | 1.0000, 1.0000, 0.0000 |
49
- | MeanFlow on `dx/dt = x`: `1.718x = x + 0.718x` | 1.718, 1, 0.718 | 1.7183, 1.0000, 0.7183 |
50
- | Shortcut doubling: two steps of 0.25 against one of 0.5 | 1.297443 | 1.297443 |
51
- | Consistency distillation residual at `h = 0.01` | 8.189e-05 | 8.189e-05 |
52
- | FMLM composed target, its four KL terms and their sum | (0.06, 0.12, 0.76, 0.06), 0.030747 | the same, 0.030747 |
53
- | The two gradients in the logits at that target and student | (0.04, 0.03, -0.11, 0.04) and (0.0099, 0.01335, -0.03315, 0.0099) | the same |
54
- | The same two once the student saturates to `q_1 = 1e-06` | -0.0600 and -6.10e-08 | -0.0600 and -4.6171e-08, see below |
55
- | The decoding clock at 0.60, 0.75, 0.80, 0.85 | 0.006, 0.141, 0.456, 0.931 | 0.0057, 0.1411, 0.4561, 0.9313 |
56
- | Four steps on the clock | 0.773490, 0.804481, 0.828095 | the same |
57
- | Gamma on the last three times, and over the long first leg | 0.494620, 0.830144 | the same |
58
- | Categorical gap and drift, and the bound around a zero residual | antiparallel, 0.199 | 1e-34 residual, 0.1989 |
59
- | The same two terms averaged over three thousand states | 0.393, 0.095 | 0.3955, 0.0963 |
60
- | Discrete derivative target, three entries negative | (-0.3333, -0.6667, 1.3333, -0.3333) | the same |
61
- | The logit repair of (-0.284, 0.828, 0.456) | (0.0194, 0.6259, 0.3547) | the same |
62
- | The weak map's floor `2K_1` | 6.389 | 6.3886 by Monte Carlo |
63
- | Chen fusion of four half-interval draws | 0.50, -0.20 | 0.50, -0.20 |
64
- | Local clocks at `t = 0.8`, and omega at (0.3, 0.6, 0.9) | 0.800, 0.692, 0.333 and 0.125 | the same |
65
- | Jensen gap, and `E[V_K]` at K = 1, 2, 4, 8 | 0.434 and 1.000, 1.217, 1.340, 1.393 | the same |
66
- | Meta Flow Maps fixed point against the per-sample ratio | 0.625 and 0.540 | the same |
67
-
68
- One of those disagrees with the notes. Section 6.27.4 quotes a saturated squared-error gradient of
69
- `-6.10e-08`. Its own expression, the gradient of half the squared error through the softmax
70
- Jacobian,
71
-
72
- d/dz_i (1/2) sum_j (q_j - p_j)^2 = q_i (q_i - p_i) - q_i sum_j q_j (q_j - p_j) ,
73
-
74
- evaluated at `p = (0.06, 0.12, 0.76, 0.06)` and the student moved to `q_1 = 1e-06` with the other
75
- three renormalized, gives `-4.6171e-08`. The first term on its own is `-6.00e-08`, so the quoted
76
- number is that term with the second one dropped. `fmlm.py` prints the full expression, takes both
77
- gradients by automatic differentiation, and reproduces the unsaturated pair of Section 6.27.4
78
- exactly. The point the chapter draws from the number is unaffected, since `-0.0600` against
79
- `-4.6171e-08` is still six orders of magnitude.
80
-
81
- The trained numbers from one default run on a laptop CPU, with seed 0.
82
-
83
- | Run | Headline |
84
- | --- | --- |
85
- | `residuals.py`, 600 steps each | distance to its own ODE 0.900 for the diagonal alone, 0.481, 0.376 and 0.541 with the Lagrangian, Eulerian and composition residuals; fraction on a mode 0.44 against 0.74, 0.66 and 0.70 |
86
- | `meanflow.py`, 2000 steps | nearest mode 0.627 at one step, 0.522 at two, 0.505 at four, all four modes found |
87
- | `shortcut.py`, 2000 steps | nearest mode 0.605 at one step and 0.508 at eight; the doubling residual rises from below 1e-04 at `d = 1/128` to 0.034 at `d = 1/2` |
88
- | `consistency.py`, 1500 steps each | nearest mode 0.513 for the consistency model at one step, 0.515, 0.420 and 0.470 for the trajectory model at one, two and four |
89
- | `fmlm.py`, 1500 steps | GC 0.586 at one step against 0.581 in the training data, motif 0.63, token entropy 1.371 of a possible 1.386, every sequence distinct |
90
- | `categorical_map.py`, 1000 steps each | every phrase in the language at both step counts; trained on the residual, 0.56 of one-step phrases distinct at entropy 2.75; trained on the bound, 0.33 at 2.54, and 0.12 at 2.05 by four steps |
91
- | `discrete_map.py`, 1500 steps | GC 0.609 at one step, motif 0.81, entropy 1.362, every sequence distinct; the unrepaired additive target is negative on 0.05% of its entries |
92
- | `stochastic_map.py` | the strong map's terminal cloud sits 8.8e-04 from the finest crossing at two hops, 1.1e-04 at four, 1.4e-05 at eight and 2e-06 at sixteen, the weak map's 3.06 at every hop count |
93
- | `expanding.py`, 1500 steps | GC 0.617 at one step, 0.621 on coordinates born at the source and 0.613 on coordinates born later; motif 0.75 |
94
- | `posterior_map.py` | on the blobs, the exact value -0.1127, the 16-sample estimate -0.1088, the single endpoint -0.1628 |
95
-
96
- The DNA files draw 1024 training sequences of the default length 16, which have GC fraction 0.581
97
- and motif fraction 0.749. They read 64 samples, as the phrase file does, so a reported fraction
98
- carries about 0.06 of sampling noise and its second digit moves between seeds.
99
-
100
- Longer runs, where they change the picture:
101
-
102
- - `residuals.py --steps 2400` moves the four distances to 1.033, 0.171, 0.247 and 0.511 and the
103
- four mode fractions to 0.27, 0.82, 0.71 and 0.79, against Table 6.2's 0.896, 0.292, 0.242 and
104
- 0.325 at 0.12, 0.84, 0.82 and 0.84. The gap between the anchor alone and the anchor plus any
105
- residual widens with the budget, and the three residuals stay inside each other's spread.
106
- - `meanflow.py --steps 8000` brings the one-step nearest mode from 0.627 to 0.437, below its own
107
- two-step and four-step figures, which is the one-step map overtaking the grid.
108
- - `categorical_map.py --steps 3000` widens the gap between the two students, to 0.60 of one-step
109
- phrases distinct for the residual against 0.27 for the bound, and 0.54 against 0.09 at four
110
- steps. The bounded student loses variety as its budget grows, which is what Section 6.28.4
111
- predicts, since the bound is minimized by a prediction that does not move with the end time.
112
- - The DNA runs are at their plateau by their default budgets. At `--steps 6000` the one-step motif
113
- fraction of `fmlm.py` reads 0.61 and of `discrete_map.py` 0.69, inside the sampling noise of the
114
- default run.
115
-
116
- Timings at the default flags, on one core. Three or four seconds for `flow_map.py`,
117
- `stochastic_map.py` and `posterior_map.py`, which train nothing, and twelve to twenty-seven
118
- seconds for the eight that do, including `residuals.py` with its four models and
119
- `categorical_map.py` and `consistency.py` with their two.
120
-
121
- ## What is simplified
122
-
123
- - The four blob models of `residuals.py` train for 600 steps on a four-layer network, so the
124
- distances are larger than Table 6.2 of the chapter, which trains longer. The ordering the
125
- chapter draws, the anchor alone against the anchor plus any residual, comes out at this budget.
126
- - `shortcut.py` evaluates the zero-step term and the doubling term on the whole batch and weights
127
- them three to one, in place of the chapter's split of the batch in that proportion. The two have
128
- the same expectation and the weighted form costs one network call more per step.
129
- - `consistency.py` distils from the exact marginal velocity of the training set, which is a
130
- softmax over the data points, in place of a separately trained flow matching teacher. The
131
- teacher's discretization error is then the only error the student inherits, which is the point
132
- of Section 6.20.
133
- - `posterior_map.py` builds the inner map from the exact posterior over the training points, in
134
- place of training a conditional family, so the inner velocity is a closed form and the value,
135
- its gradient and the Jensen gap are all available to compare against.
136
- - The decoding clock of `fmlm.py` is computed by quadrature on the one-position integral, which is
137
- exact for the Gaussian interpolant and says nothing about correlations between positions.
138
- - `categorical_map.py` and `discrete_map.py` keep the end time below one, because the drift term
139
- of each loss divides by the remaining time. `fmlm.py` has no such term, so it draws a quarter of
140
- each batch at `t = 1`, the slice Section 6.26 asks for by name.
141
- - `stochastic_map.py` carries three Legendre coefficients and fuses them with the Chen relations
142
- written out for `n = 0, 1, 2`. A fourth coefficient needs the constants `c_{3,m}`, which the
143
- chapter does not list.
144
- - Every network is `cis6270.nets.TwoTimeMLP`. The discrete files flatten the one-hot lift into its
145
- input and read its output as per-position logits, so one architecture covers both halves of the
146
- lecture and any difference in the samples comes from the loss.
147
- - Each file sets `torch.set_num_threads(1)`. These networks are small enough that splitting one
148
- matrix product across cores costs more than it saves. `tests/conftest.py` sets the same thing
149
- for the test session, so the line in each file is the one that acts when a reader runs the
150
- script directly.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_6/categorical_map.py DELETED
@@ -1,244 +0,0 @@
1
- """Categorical Flow Maps, the Lagrangian identity on the simplex and the bound that replaces it.
2
-
3
- A softmax head makes the two-time prediction a distribution at every pair of times, and the
4
- Lagrangian identity rewritten in those coordinates reads pi_{s,t} = pi_{t,t}(X_{s,t}) minus a
5
- coefficient times the end-time derivative of the prediction. The derivative has no sign
6
- constraint, so the residual splits into a teacher gap, which is a difference of two distributions,
7
- and a drift, which is the scaled derivative. A parallelogram bound and Pinsker's inequality turn
8
- the squared residual into four KL divergences plus two squared drifts, and at the exact map the
9
- two pieces are antiparallel, so the bound is strictly positive where the residual is zero.
10
-
11
- Chapter 6, Section 6.28 of the notes. Trained on the four-token phrases of
12
- ``cis6270.data.phrases``. Prints the chapter's worked cancellation on three tokens, where the gap
13
- is the exact negative of the drift and the bound reads 0.199 around a residual of zero, and then
14
- two models trained on the residual and on its bound.
15
-
16
- python lecture_6/categorical_map.py # two models, 1000 steps each
17
- python lecture_6/categorical_map.py --steps 20 # a few seconds, for a first look
18
- """
19
-
20
- # %% 01. Imports and configuration
21
- import torch
22
-
23
- from cis6270.data import phrases
24
- from cis6270.metrics import token_entropy, unique_fraction
25
- from cis6270.nets import TwoTimeMLP, count_parameters
26
- from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
27
-
28
-
29
- torch.set_num_threads(1) # These networks are small, and splitting one matrix product costs more
30
- # than it saves. ``tests/conftest.py`` sets the same thing for the test session, so the line
31
- # here is the one that acts when a reader runs this script directly.
32
-
33
- BOOK_PRIOR = (0.5, 0.3, 0.2) # The three data probabilities of the chapter's worked example.
34
- BOOK_SOURCE = (-0.5739, -0.8344, -0.0584) # The source draw I_0 the chapter starts from.
35
- BOOK_STATES = 3000 # States the chapter averages the two terms of the bound over.
36
-
37
-
38
- # %% 02. The exact model on three tokens
39
- #
40
- # With a Gaussian source and the linear interpolant, the posterior over the data token is a
41
- # softmax whose only state dependence is the coordinate of that token. The velocity follows from
42
- # D_t(x) = x + (1-t) b(x,t), and the flow integrates to twelve digits with Runge-Kutta.
43
- def exact_denoiser(x, t, prior):
44
- """pi_{t,t}(x), the posterior over the data token given the state. [B, K]."""
45
- return torch.softmax(prior.log() + t * x / (1.0 - t) ** 2, dim=-1)
46
-
47
-
48
- def exact_velocity(x, t, prior):
49
- """b(x,t) = (pi_{t,t}(x) - x)/(1 - t) for the linear interpolant. [B, K]."""
50
- return (exact_denoiser(x, t, prior) - x) / (1.0 - t)
51
-
52
-
53
- def exact_flow(x, s, t, prior, n: int = 2000):
54
- """X_{s,t}(x) by fourth-order Runge-Kutta on the exact velocity."""
55
- h = (t - s) / n
56
- for k in range(n):
57
- u = s + k * h
58
- k1 = exact_velocity(x, u, prior)
59
- k2 = exact_velocity(x + 0.5 * h * k1, u + 0.5 * h, prior)
60
- k3 = exact_velocity(x + 0.5 * h * k2, u + 0.5 * h, prior)
61
- k4 = exact_velocity(x + h * k3, u + h, prior)
62
- x = x + (h / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
63
- return x
64
-
65
-
66
- def exact_two_time(x, s, t, prior):
67
- """pi_{s,t}(x) read off the convex parameterization, [(1-s) X_{s,t}(x) - (1-t) x]/(t-s)."""
68
- return ((1.0 - s) * exact_flow(x, s, t, prior) - (1.0 - t) * x) / (t - s)
69
-
70
-
71
- # %% 03. The coefficient, the gap and the drift
72
- #
73
- # gamma_{s,t} = (t-s)(1-t)/(1-s) vanishes on the diagonal and at t = 1 and never exceeds 1/4 on
74
- # the square. The residual scaled by the remaining time splits as (1-t) r = a + b, with a the gap
75
- # between the student's prediction and the prediction at the transported state.
76
- def gamma_coefficient(s, t):
77
- """gamma_{s,t} = (t-s)(1-t)/(1-s)."""
78
- return (t - s) * (1.0 - t) / (1.0 - s).clamp(min=1e-6)
79
-
80
-
81
- def end_time_derivative(x, s, t, prior, n: int = 2000):
82
- """d pi_{s,t}/dt from the integral representation, (1-s)/((t-s)(1-t)) times the bracket."""
83
- transported = exact_flow(x, s, t, prior, n)
84
- prediction = ((1.0 - s) * transported - (1.0 - t) * x) / (t - s)
85
- at_output = exact_denoiser(transported, t, prior)
86
- return (1.0 - s) / ((t - s) * (1.0 - t)) * (at_output - prediction), prediction, at_output
87
-
88
-
89
- # %% 04. The worked cancellation of Section 6.28
90
- #
91
- # The derivative above comes from the closed form of Section 6.26, which already carries the
92
- # cancellation inside it, so the central difference of pi_{s,t} in t below is the entry that checks
93
- # the two pieces against each other without assuming the identity.
94
- def worked_cancellation(s: float = 0.2, t: float = 0.6):
95
- """The three-token example, with every number integrated or differentiated here."""
96
- prior = torch.tensor(BOOK_PRIOR, dtype=torch.float64)
97
- source = torch.tensor(BOOK_SOURCE, dtype=torch.float64)
98
- x = (1.0 - s) * source + s * torch.eye(3, dtype=torch.float64)[0] # Data token is the first.
99
- derivative, prediction, at_output = end_time_derivative(x, s, t, prior)
100
- gamma = (t - s) * (1.0 - t) / (1.0 - s)
101
- gap = prediction - at_output # a_{s,t}, a difference of two simplex points.
102
- drift = gamma * derivative # b_{s,t}, the scaled derivative of the prediction.
103
- divergence = (at_output * (at_output / prediction).log()).sum() # KL(teacher || student).
104
- difference = (exact_two_time(x, s, t + 1e-5, prior)
105
- - exact_two_time(x, s, t - 1e-5, prior)) / 2e-5 # A central check.
106
- return {
107
- "state x": [round(v, 4) for v in x.tolist()],
108
- "X_s,t(x)": [round(v, 4) for v in exact_flow(x, s, t, prior).tolist()],
109
- "pi_s,t(x)": [round(v, 4) for v in prediction.tolist()],
110
- "pi_t,t(X_s,t(x))": [round(v, 4) for v in at_output.tolist()],
111
- "gamma_s,t": gamma,
112
- "d pi / dt": [round(v, 4) for v in derivative.tolist()],
113
- "central difference": [round(v, 4) for v in difference.tolist()],
114
- "gap a": [round(v, 3) for v in gap.tolist()],
115
- "drift b": [round(v, 3) for v in drift.tolist()],
116
- "residual |a + b|^2": float(((gap + drift) ** 2).sum()),
117
- "4 KL": 4.0 * float(divergence),
118
- "2 |b|^2": 2.0 * float((drift ** 2).sum()),
119
- "the bound": 4.0 * float(divergence) + 2.0 * float((drift ** 2).sum()),
120
- "teacher entropy": -float((at_output * at_output.log()).sum()),
121
- "cross-entropy": -float((at_output * prediction.log()).sum()),
122
- }
123
-
124
-
125
- def averaged_bound(count: int = BOOK_STATES, s: float = 0.2, t: float = 0.6, seed: int = 0,
126
- n: int = 400):
127
- """The same two terms averaged over states drawn from the interpolant at the same times."""
128
- prior = torch.tensor(BOOK_PRIOR, dtype=torch.float64)
129
- generator = torch.Generator().manual_seed(seed)
130
- which = torch.multinomial(prior.expand(count, 3), 1, generator=generator).squeeze(1)
131
- x1 = torch.eye(3, dtype=torch.float64)[which] # [N, 3] the lifted data token.
132
- x0 = torch.randn(count, 3, generator=generator, dtype=torch.float64) # [N, 3] the source.
133
- x = (1.0 - s) * x0 + s * x1 # [N, 3] the interpolant at the start time.
134
- derivative, prediction, at_output = end_time_derivative(x, s, t, prior, n)
135
- drift = ((t - s) * (1.0 - t) / (1.0 - s)) * derivative
136
- divergence = (at_output * (at_output / prediction).log()).sum(-1).mean()
137
- return {"averaged 4 KL": 4.0 * float(divergence),
138
- "averaged 2 |b|^2": 2.0 * float((drift ** 2).sum(-1).mean()),
139
- "averaged residual": float(((prediction - at_output + drift) ** 2).sum(-1).mean())}
140
-
141
-
142
- # %% 05. The student, and the two losses it can be trained on
143
- def predict(model, x, s, t, length, vocab):
144
- """pi_{s,t}(x) at every position, a softmax head over the vocabulary."""
145
- return torch.softmax(model(x.flatten(1), s, t).view(-1, length, vocab), dim=-1)
146
-
147
-
148
- def pieces(model, xs, s, t, length, vocab):
149
- """The gap a and the drift b of the split, with the teacher detached."""
150
- f = lambda tt: predict(model, xs, s, tt, length, vocab)
151
- prediction, derivative = torch.func.jvp(f, (t,), (torch.ones_like(t),)) # One forward mode pass.
152
- keep, move = (1.0 - t) / (1.0 - s), (t - s) / (1.0 - s)
153
- transported = keep[:, None, None] * xs + move[:, None, None] * prediction # X_{s,t}(x).
154
- teacher = predict(model, transported.detach(), t, t, length, vocab).detach() # pi_{t,t}.
155
- drift = gamma_coefficient(s, t)[:, None, None] * derivative
156
- return prediction - teacher, drift, teacher, prediction
157
-
158
-
159
- def make_loss(model, data, length, vocab, batch_size, device, bounded: bool):
160
- eye = torch.eye(vocab, device=device)
161
-
162
- def loss_fn(step: int) -> torch.Tensor:
163
- index = torch.randint(0, len(data), (batch_size,), device=device)
164
- x1, ids = eye[data[index]], data[index] # [B, L, K] and [B, L].
165
- x0 = torch.randn_like(x1) # [B, L, K] the Gaussian source.
166
- t_diag = torch.rand(batch_size, device=device)
167
- xt = (1 - t_diag[:, None, None]) * x0 + t_diag[:, None, None] * x1
168
- anchor = -(predict(model, xt, t_diag, t_diag, length, vocab).clamp_min(1e-9).log()
169
- .gather(-1, ids[..., None])).mean() # Cross-entropy on the diagonal.
170
-
171
- s = 0.9 * torch.rand(batch_size, device=device) # [B] start time, kept off t = 1.
172
- t = s + (0.95 - s) * torch.rand(batch_size, device=device) # [B] end time below one.
173
- xs = (1 - s[:, None, None]) * x0 + s[:, None, None] * x1
174
- gap, drift, teacher, prediction = pieces(model, xs, s, t, length, vocab)
175
- if bounded: # Four KL divergences plus two squared drifts, the bound of Section 6.28.
176
- divergence = (teacher * (teacher.clamp_min(1e-9)
177
- / prediction.clamp_min(1e-9)).log()).sum(-1).mean()
178
- return anchor + 4.0 * divergence + 2.0 * (drift ** 2).sum(-1).mean()
179
- return anchor + ((gap + drift) ** 2).sum(-1).mean() # The residual itself.
180
-
181
- return loss_fn
182
-
183
-
184
- # %% 06. Sampling, and what a valid phrase is
185
- @torch.no_grad()
186
- def sample(model, count, length, vocab, steps, device):
187
- """Walk a uniform grid with the convex form and read the phrase off the last prediction."""
188
- x = torch.randn(count, length, vocab, device=device)
189
- grid = torch.linspace(0.0, 1.0, steps + 1)
190
- for a, b in zip(grid[:-1], grid[1:]):
191
- s, t = a.expand(count).to(device), b.expand(count).to(device)
192
- prediction = predict(model, x, s, t, length, vocab)
193
- keep, move = (1.0 - t) / (1.0 - s), (t - s) / (1.0 - s)
194
- x = keep[:, None, None] * x + move[:, None, None] * prediction
195
- return x.argmax(-1) # [N, L].
196
-
197
-
198
- def valid_fraction(drawn, group: int = 4):
199
- """A phrase is in the language when position i holds a token from the i-th quarter."""
200
- slots = torch.arange(drawn.shape[1], device=drawn.device) # [L].
201
- return float(((drawn // group) == slots[None, :]).all(dim=1).to(torch.get_default_dtype()).mean())
202
-
203
-
204
- # %% 07. The report
205
- def main() -> None:
206
- parser = common_parser(__doc__.split("\n")[0])
207
- parser.set_defaults(steps=1000, batch_size=128, samples=256, sample_steps=4)
208
- args = parser.parse_args()
209
- set_seed(args.seed)
210
- device = pick_device(args.device)
211
- tokens, vocabulary = phrases(num_phrases=1024, seed=args.seed)
212
- data, length, vocab = tokens.to(device), tokens.shape[1], len(vocabulary)
213
-
214
- report = worked_cancellation()
215
- report.update(averaged_bound(seed=args.seed)) # The chapter's three thousand states.
216
- if not args.quiet:
217
- print(f"gap {report['gap a']} and drift {report['drift b']} cancel to "
218
- f"{report['residual |a + b|^2']:.2e}, bound {report['the bound']:.3f}")
219
-
220
- samples: list[str] = []
221
- for name, bounded in (("the residual", False), ("the bound", True)):
222
- set_seed(args.seed) # Both students start from the same weights and see the same batches.
223
- model = TwoTimeMLP(dim=length * vocab, width=args.width).to(device)
224
- if not args.quiet:
225
- print(f"\ntraining on {name} parameters={count_parameters(model):,}")
226
- losses = train(model, make_loss(model, data, length, vocab, args.batch_size, device,
227
- bounded), steps=args.steps, lr=args.lr, quiet=args.quiet)
228
- for steps in (1, args.sample_steps):
229
- torch.manual_seed(args.seed + 1)
230
- drawn = sample(model, args.samples, length, vocab, steps, device)
231
- report[f"{name}, {steps} step(s): valid"] = valid_fraction(drawn)
232
- report[f"{name}, {steps} step(s): distinct"] = unique_fraction(drawn)
233
- report[f"{name}, {steps} step(s): entropy"] = token_entropy(drawn, vocab)
234
- report[f"{name}: final loss"] = sum(losses[-20:]) / len(losses[-20:])
235
- drawn = sample(model, 4, length, vocab, args.sample_steps, device)
236
- samples.append(f"{name:<14s} " + " | ".join(
237
- " ".join(vocabulary[int(i)] for i in row) for row in drawn[:2]))
238
-
239
- print_report("Categorical Flow Maps, Chapter 6", report, samples)
240
- save_run(args.out, config=vars(args), report=report, samples=samples)
241
-
242
-
243
- if __name__ == "__main__":
244
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_6/consistency.py DELETED
@@ -1,231 +0,0 @@
1
- """Consistency models and consistency trajectory models, one time fixed and then both free.
2
-
3
- A consistency model fixes the end time at the data end and learns the top edge of the triangle of
4
- start and end times, with the identity axiom imposed at the corner by the boundary coefficients
5
- c_skip and c_out. A consistency trajectory model puts the second time back and learns the whole
6
- triangle, writing the map as a weighted average of the input and a head that predicts a
7
- destination. Both are distilled here from a teacher velocity available in closed form.
8
-
9
- Chapter 6, Section 6.20 of the notes. Trained on the four Gaussian blobs of
10
- ``cis6270.data.gaussian_mixture_2d``. Prints the boundary parameterization on dx/dt = x, where the
11
- model returns e^{0.5}, e^{0.1} and 1 at three start times, the teacher's error floor of
12
- 8.189e-05, and the destination head of a consistency trajectory model at two pairs of times.
13
-
14
- python lecture_6/consistency.py # two models, 1500 steps each
15
- python lecture_6/consistency.py --steps 20 # a few seconds, for a first look
16
- """
17
-
18
- # %% 01. Imports and configuration
19
- import math
20
-
21
- import torch
22
-
23
- from cis6270.data import gaussian_mixture_2d
24
- from cis6270.metrics import mean_log_density, mode_report
25
- from cis6270.nets import TwoTimeMLP, count_parameters
26
- from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
27
-
28
-
29
- torch.set_num_threads(1) # These networks are small, and splitting one matrix product costs more
30
- # than it saves. ``tests/conftest.py`` sets the same thing for the test session, so the line
31
- # here is the one that acts when a reader runs this script directly.
32
-
33
- TEACHER_STEP = 0.02 # h, the length of the teacher's solver step inside the distillation loss.
34
-
35
-
36
- # %% 02. The teacher velocity, in closed form on the empirical data
37
- #
38
- # The posterior over the data point given the interpolant is a softmax over the training set, so
39
- # the exact marginal velocity of the four blobs needs no training. A trained flow matching model
40
- # would serve in its place, with its own error on top of the solver's.
41
- def teacher_denoiser(x, t, data):
42
- """D_t(x) = E[X_1 | X_t = x], the posterior mean over the training points. [B, 2]."""
43
- spread = (1.0 - t).clamp(min=1e-4)[:, None] # [B, 1] the width of the conditional.
44
- squared = ((x[:, None, :] - t[:, None, None] * data[None]) ** 2).sum(-1) # [B, N].
45
- weights = torch.softmax(-squared / (2.0 * spread ** 2), dim=1) # [B, N] posterior over data.
46
- return weights @ data # [B, 2] the posterior mean.
47
-
48
-
49
- def teacher_velocity(x, t, data):
50
- """b(x,t) = (D_t(x) - x)/(1 - t) for the linear interpolant. [B, 2]."""
51
- return (teacher_denoiser(x, t, data) - x) / (1.0 - t).clamp(min=1e-4)[:, None]
52
-
53
-
54
- def solver_step(x, t, h, data):
55
- """One Euler step of the teacher, which the chapter writes as Phi_h."""
56
- return x + h * teacher_velocity(x, t, data) # [B, 2] the state at time t + h.
57
-
58
-
59
- # %% 03. The boundary parameterization, checked on the scalar flow
60
- #
61
- # f_theta(x, s) = c_skip(s) x + c_out(s) F_theta(x, s) with c_skip = 1 and c_out = 1 - s. At the
62
- # data end the output coefficient is zero and the model returns its input, so no training signal
63
- # is spent on a condition we already know.
64
- def scalar_consistency(x_value: float = 1.0):
65
- """The velocity head, the output coefficient and the model at three start times."""
66
- out = {}
67
- for s in (0.5, 0.9, 1.0):
68
- remaining = 1.0 - s # c_out(s), which vanishes at the data end.
69
- head = x_value * math.expm1(remaining) / remaining if remaining > 0 else x_value
70
- out[f"c_out({s})"] = remaining
71
- out[f"v_{s},1(1)"] = head
72
- out[f"f({s})"] = x_value + remaining * head # Equals e^{1-s} on this flow.
73
- return out
74
-
75
-
76
- def teacher_floor(x_value: float = 1.0, s: float = 0.5, h: float = 0.01):
77
- """The distillation residual at the exact map, which is the teacher's own Euler error."""
78
- exact = lambda x, t: x * math.exp(1.0 - t) # f(x, t) = x e^{1-t} on dx/dt = x.
79
- moved = x_value + h * x_value # One teacher Euler step, which undershoots e^{h}.
80
- residual = exact(x_value, s) - exact(moved, s + h)
81
- local = math.exp(h) - 1.0 - h # The local error of that step.
82
- return {"f(1, 0.5)": exact(x_value, s), "f(1.01, 0.51)": exact(moved, s + h),
83
- "distillation residual": residual, "local Euler error": local,
84
- "carried to the data end": local * math.exp(1.0 - s - h)}
85
-
86
-
87
- # %% 04. Consistency trajectory models, with both times free
88
- #
89
- # X_theta(x, s, t) = a_{s,t} x + (1 - a_{s,t}) g_theta(x, s, t) with a_{s,t} = (1-t)/(1-s), which
90
- # equals one on the diagonal, so the map returns its input there for any head whatever.
91
- def ctm_weight(s, t):
92
- """a_{s,t} = (1-t)/(1-s), the share of the input kept by the step."""
93
- return (1.0 - t) / (1.0 - s).clamp(min=1e-4)
94
-
95
-
96
- def ctm_map(model, x, s, t):
97
- """The weighted average of the input and the destination head."""
98
- a = ctm_weight(s, t)[:, None] # [B, 1].
99
- return a * x + (1.0 - a) * model(x, s, t) # [B, 2].
100
-
101
-
102
- def scalar_ctm(x_value: float = 1.0):
103
- """The head solved from the exact map at two pairs of times, and the two-route sampler."""
104
- out = {}
105
- for s, t in ((0.0, 0.5), (0.0, 1.0)):
106
- a = (1.0 - t) / (1.0 - s)
107
- reached = x_value * math.exp(t - s) # The exact map of dx/dt = x.
108
- out[f"a_{s},{t}"] = a
109
- out[f"g_{s},{t}"] = (reached - a * x_value) / (1.0 - a) # Solve the weighted average.
110
- short = x_value * math.exp(0.5) # X_{0,0.5}(1), the direct short step.
111
- long_then_back = (x_value * math.exp(1.0)) * math.exp(-0.5) # X_{1,0.5}(X_{0,1}(1)).
112
- out["short step"], out["long jump and return"] = short, long_then_back
113
- out["two routes apart"] = abs(short - long_then_back)
114
- return out
115
-
116
-
117
- # %% 05. The two losses
118
- #
119
- # Both compare the model at one time against the model at a state the teacher has advanced by h,
120
- # with the target detached, which is the frozen copy of the weights the chapter writes as theta
121
- # minus. The consistency model holds the end time at one; the trajectory model draws it.
122
- def make_consistency_loss(model, data, batch_size, device):
123
- ones = torch.ones(batch_size, device=device)
124
-
125
- def loss_fn(step: int) -> torch.Tensor:
126
- index = torch.randint(0, len(data), (batch_size,), device=device)
127
- x1, x0 = data[index], torch.randn(batch_size, 2, device=device)
128
- s = (1.0 - TEACHER_STEP) * torch.rand(batch_size, device=device) # [B] with s + h <= 1.
129
- xs = (1 - s[:, None]) * x0 + s[:, None] * x1 # [B, 2] the interpolant at the start time.
130
- with torch.no_grad(): # The second leg is the teacher's, and no gradient reaches it.
131
- advanced = solver_step(xs, s, TEACHER_STEP, data)
132
- target = model.flow_map(advanced, s + TEACHER_STEP, ones) # f_{theta-}(Phi_h(x_s), s+h).
133
- return ((model.flow_map(xs, s, ones) - target) ** 2).mean()
134
-
135
- return loss_fn
136
-
137
-
138
- def make_trajectory_loss(model, data, batch_size, device):
139
-
140
- def loss_fn(step: int) -> torch.Tensor:
141
- index = torch.randint(0, len(data), (batch_size,), device=device)
142
- x1, x0 = data[index], torch.randn(batch_size, 2, device=device)
143
- s = (1.0 - TEACHER_STEP) * torch.rand(batch_size, device=device) # [B] start time.
144
- t = s + TEACHER_STEP + (1.0 - s - TEACHER_STEP) * torch.rand(batch_size, device=device)
145
- xs = (1 - s[:, None]) * x0 + s[:, None] * x1 # [B, 2].
146
- with torch.no_grad():
147
- advanced = solver_step(xs, s, TEACHER_STEP, data)
148
- target = ctm_map(model, advanced, s + TEACHER_STEP, t) # The same end time, one h later.
149
- return ((ctm_map(model, xs, s, t) - target) ** 2).mean()
150
-
151
- return loss_fn
152
-
153
-
154
- # %% 06. Sampling
155
- @torch.no_grad()
156
- def consistency_sample(model, x0):
157
- """One call of the edge map, from the source straight to the data end."""
158
- zeros = torch.zeros(len(x0), device=x0.device)
159
- return model.flow_map(x0, zeros, torch.ones_like(zeros)) # [N, 2].
160
-
161
-
162
- @torch.no_grad()
163
- def trajectory_sample(model, x0, steps: int):
164
- """Cross the triangle in equal pieces, one evaluation of the weighted average per piece."""
165
- x = x0
166
- grid = torch.linspace(0.0, 1.0, steps + 1, device=x0.device) # [steps+1] the time grid.
167
- for s, t in zip(grid[:-1], grid[1:]):
168
- x = ctm_map(model, x, s.expand(len(x)), t.expand(len(x)))
169
- return x # [N, 2].
170
-
171
-
172
- # %% 07. The report
173
- def main() -> None:
174
- parser = common_parser(__doc__.split("\n")[0])
175
- parser.set_defaults(steps=1500, batch_size=128, samples=512)
176
- args = parser.parse_args()
177
- set_seed(args.seed)
178
- device = pick_device(args.device)
179
- data = gaussian_mixture_2d(num_points=1024, seed=args.seed).to(device) # [1024, 2].
180
-
181
- report = scalar_consistency()
182
- report.update(teacher_floor())
183
- report.update(scalar_ctm())
184
- if not args.quiet:
185
- print(f"boundary parameterization: f(0.5) = {report['f(0.5)']:.6f}, "
186
- f"f(0.9) = {report['f(0.9)']:.6f}, f(1.0) = {report['f(1.0)']:.6f}")
187
- print(f"teacher floor {report['distillation residual']:.6e} against the carried local "
188
- f"error {report['carried to the data end']:.6e}")
189
-
190
- samples: list[str] = []
191
- set_seed(args.seed)
192
- consistency = TwoTimeMLP(dim=2, width=args.width).to(device)
193
- if not args.quiet:
194
- print(f"\ntraining the consistency model parameters={count_parameters(consistency):,}")
195
- edge_losses = train(consistency,
196
- make_consistency_loss(consistency, data, args.batch_size, device),
197
- steps=args.steps, lr=args.lr, quiet=args.quiet)
198
- report["consistency final loss"] = sum(edge_losses[-20:]) / len(edge_losses[-20:])
199
- torch.manual_seed(args.seed + 1)
200
- x0 = torch.randn(args.samples, 2, device=device) # [N, 2] the shared source points.
201
- points = consistency_sample(consistency, x0)
202
- stats = mode_report(points)
203
- report["consistency one-step nearest"] = stats["nearest_distance"]
204
- report["consistency one-step log density"] = mean_log_density(points.cpu())
205
- samples.append(f"consistency, 1 step: nearest {stats['nearest_distance']:.3f} "
206
- f"modes {stats['modes_found']} entropy {stats['mode_entropy']:.3f}")
207
-
208
- set_seed(args.seed)
209
- trajectory = TwoTimeMLP(dim=2, width=args.width).to(device)
210
- if not args.quiet:
211
- print(f"\ntraining the trajectory model parameters={count_parameters(trajectory):,}")
212
- losses = train(trajectory, make_trajectory_loss(trajectory, data, args.batch_size, device),
213
- steps=args.steps, lr=args.lr, quiet=args.quiet)
214
- for steps in (1, 2, 4):
215
- points = trajectory_sample(trajectory, x0, steps)
216
- stats = mode_report(points)
217
- report[f"trajectory {steps}-step nearest"] = stats["nearest_distance"]
218
- samples.append(f"trajectory, {steps} step(s): nearest {stats['nearest_distance']:.3f} "
219
- f"modes {stats['modes_found']} entropy {stats['mode_entropy']:.3f}")
220
- report["trajectory one step against four"] = float(
221
- (trajectory_sample(trajectory, x0, 1) - trajectory_sample(trajectory, x0, 4)).norm(dim=1)
222
- .mean())
223
- report["trajectory final loss"] = sum(losses[-20:]) / len(losses[-20:])
224
-
225
- print_report("Consistency and consistency trajectory models, Chapter 6", report, samples)
226
- save_run(args.out, model=trajectory, config=vars(args), report=report, losses=losses,
227
- samples=samples)
228
-
229
-
230
- if __name__ == "__main__":
231
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_6/discrete_map.py DELETED
@@ -1,218 +0,0 @@
1
- """Discrete Flow Maps, the derivative identities repaired in logit space.
2
-
3
- A curve that stays on the simplex has coordinates summing to one, so its velocity has coordinates
4
- summing to zero, and a target built from that velocity has a negative entry as soon as one
5
- coordinate of the prediction is smaller than the derivative pulling it down. Written in logits the
6
- same derivative becomes a positive multiplicative factor, dividing by it keeps every coordinate
7
- positive, and a softmax normalizes what is left, so the Kullback-Leibler divergence against the
8
- repaired target is defined at every state.
9
-
10
- Chapter 6, Sections 6.26 and 6.29 of the notes. Trained on the synthetic DNA of
11
- ``cis6270.data.synthetic_dna``. Prints the derivative target of Section 6.26, three of whose four
12
- entries are negative, the three-token repair, where the additive target (-0.284, 0.828, 0.456)
13
- becomes the distribution (0.0194, 0.6259, 0.3547), and samples from the trained map.
14
-
15
- python lecture_6/discrete_map.py # 1500 steps, about half a minute
16
- python lecture_6/discrete_map.py --steps 20 # a few seconds, for a first look
17
- """
18
-
19
- # %% 01. Imports and configuration
20
- import torch
21
-
22
- from cis6270.data import DNA_SIZE, decode_dna, synthetic_dna
23
- from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
24
- from cis6270.nets import TwoTimeMLP, count_parameters
25
- from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
26
-
27
-
28
- torch.set_num_threads(1) # These networks are small, and splitting one matrix product costs more
29
- # than it saves. ``tests/conftest.py`` sets the same thing for the test session, so the line
30
- # here is the one that acts when a reader runs this script directly.
31
-
32
- FLOOR = 1e-3 # The factor is clipped away from zero before the logarithm, as the chapter notes.
33
-
34
-
35
- # %% 02. Why the derivative target leaves the simplex
36
- #
37
- # d delta_{s,t}/dt = (1-s)/((t-s)(1-t)) [D_t(X_{s,t}(x)) - delta_{s,t}(x)]. The bracket is a
38
- # difference of two simplex points, so its coordinates sum to zero, and the prefactor only scales
39
- # it up. Nothing about the alphabet or the prior changes the sign pattern.
40
- def derivative_target(prediction, at_output, s: float, t: float):
41
- """The end-time derivative of the two-time denoiser, a direction and not a distribution."""
42
- prefactor = (1.0 - s) / ((t - s) * (1.0 - t)) # 6.6667 at s = 0.2, t = 0.8.
43
- return prefactor * (at_output - prediction), prefactor
44
-
45
-
46
- def worked_derivative():
47
- """The chapter's DNA example at s = 0.2, t = 0.8, whose target has three negative entries."""
48
- prediction = torch.tensor([0.10, 0.20, 0.60, 0.10]) # delta_{0.2,0.8} over (A, C, G, T).
49
- at_output = torch.tensor([0.05, 0.10, 0.80, 0.05]) # D_{0.8} at the transported state.
50
- target, prefactor = derivative_target(prediction, at_output, 0.2, 0.8)
51
- return {"derivative prefactor": prefactor,
52
- "bracket": [round(v, 4) for v in (at_output - prediction).tolist()],
53
- "derivative target": [round(v, 4) for v in target.tolist()],
54
- "target sum": float(target.sum()),
55
- "negative entries": int((target < 0).sum())}
56
-
57
-
58
- # %% 03. Differentiating a softmax
59
- #
60
- # The Jacobian of the softmax is diag(psi) - psi psi^T, so contracting it with the logit
61
- # derivative gives psi times the centered derivative. The centring makes the coordinates of
62
- # d psi/dt sum to zero and makes the identity insensitive to the shift invariance of the softmax.
63
- def centered(prediction, logit_derivative):
64
- """c_{s,t} = d z/dt - <psi, d z/dt> 1, the quantity the whole identity is built on."""
65
- mean = (prediction * logit_derivative).sum(-1, keepdim=True) # <psi, dz/dt>.
66
- return logit_derivative - mean # [..., K] with <psi, c> = 0.
67
-
68
-
69
- # %% 04. The identity as a multiplicative relation, and the repaired teacher
70
- #
71
- # psi_{t,t}(X_{s,t}(x)) = psi_{s,t}(x) * (1 + gamma c_{s,t}(x)) coordinatewise. Solving for the
72
- # student divides by a strictly positive factor, and in logits the division is a subtraction of
73
- # its logarithm, so the target is a softmax of an expression the network already produces.
74
- def gamma_lagrangian(s, t):
75
- """gamma_{s,t} = (t-s)(1-t)/(1-s), which never exceeds 1/4 on the square."""
76
- return (t - s) * (1.0 - t) / (1.0 - s).clamp(min=1e-6)
77
-
78
-
79
- def gamma_eulerian(s, t):
80
- """gamma^E_{s,t} = (1-s)(t-s)/(1-t), larger than the Lagrangian one and growing toward t = 1."""
81
- return (1.0 - s) * (t - s) / (1.0 - t).clamp(min=1e-6)
82
-
83
-
84
- def repaired_teacher(teacher_logits, factor):
85
- """Softmax(z_{t,t}(X_{s,t}(x)) - log(1 + gamma c)), a distribution by construction."""
86
- return torch.softmax(teacher_logits - factor.clamp(min=FLOOR).log(), dim=-1)
87
-
88
-
89
- # %% 05. The repair worked on three tokens
90
- def worked_repair(s: float = 0.2, t: float = 0.6):
91
- """The chapter's three-token repair, with the additive target beside the multiplicative one."""
92
- prediction = torch.tensor([0.2, 0.5, 0.3]) # psi_{s,t}(x).
93
- logit_derivative = torch.tensor([9.60, -2.28, -2.60]) # d z_{s,t}/dt.
94
- gamma = (t - s) * (1.0 - t) / (1.0 - s)
95
- c = centered(prediction, logit_derivative) # Already centered on this example.
96
- derivative = prediction * c # d psi/dt, whose coordinates sum to zero.
97
- teacher = torch.tensor([0.10, 0.60, 0.30]) # psi_{t,t}(X_{s,t}(x)) during training.
98
- additive = teacher - gamma * derivative # Read straight off the identity.
99
- factor = 1.0 + gamma * c # The multiplicative factor, positive in every coordinate.
100
- quotient = teacher / factor # The teacher divided coordinatewise.
101
- return {
102
- "weighted average of dz/dt": float((prediction * logit_derivative).sum()),
103
- "centered derivative": [round(v, 4) for v in c.tolist()],
104
- "d psi/dt": [round(v, 4) for v in derivative.tolist()],
105
- "d psi/dt sum": float(derivative.sum()),
106
- "additive target": [round(v, 4) for v in additive.tolist()],
107
- "additive target sum": float(additive.sum()),
108
- "additive negative entries": int((additive < 0).sum()),
109
- "multiplicative factor": [round(v, 4) for v in factor.tolist()],
110
- "quotient": [round(v, 4) for v in quotient.tolist()],
111
- "quotient mass": float(quotient.sum()),
112
- "repaired target": [round(v, 4) for v in (quotient / quotient.sum()).tolist()],
113
- "log of the factor": [round(v, 4) for v in factor.log().tolist()],
114
- "gamma_s,t": gamma,
115
- "gamma^E_s,t": (1.0 - s) * (t - s) / (1.0 - t),
116
- }
117
-
118
-
119
- # %% 06. The student, and the loss built on the repaired target
120
- def logits_of(model, x, s, t, length, vocab):
121
- """z_{s,t}(x), one vector of length K per position."""
122
- return model(x.flatten(1), s, t).view(-1, length, vocab) # [B, L, K].
123
-
124
-
125
- def make_loss(model, data, length, vocab, batch_size, device, counter):
126
- eye = torch.eye(vocab, device=device)
127
-
128
- def loss_fn(step: int) -> torch.Tensor:
129
- index = torch.randint(0, len(data), (batch_size,), device=device)
130
- ids = data[index] # [B, L] the data tokens.
131
- x1, x0 = eye[ids], torch.randn(batch_size, length, vocab, device=device)
132
- t_diag = torch.rand(batch_size, device=device)
133
- xt = (1 - t_diag[:, None, None]) * x0 + t_diag[:, None, None] * x1
134
- diagonal_logits = logits_of(model, xt, t_diag, t_diag, length, vocab)
135
- anchor = torch.nn.functional.cross_entropy( # KL to the one-hot token, the diagonal anchor.
136
- diagonal_logits.reshape(-1, vocab), ids.reshape(-1))
137
-
138
- s = 0.9 * torch.rand(batch_size, device=device) # [B] start time.
139
- t = s + (0.95 - s) * torch.rand(batch_size, device=device) # [B] end time below one.
140
- xs = (1 - s[:, None, None]) * x0 + s[:, None, None] * x1 # [B, L, K].
141
- f = lambda tt: logits_of(model, xs, s, tt, length, vocab)
142
- z, dz = torch.func.jvp(f, (t,), (torch.ones_like(t),)) # One forward-mode pass in t.
143
- prediction = torch.softmax(z, dim=-1) # psi_{s,t}(x).
144
- gamma = gamma_lagrangian(s, t)[:, None, None]
145
- factor = 1.0 + gamma * centered(prediction, dz) # The multiplicative factor.
146
- keep, move = (1.0 - t) / (1.0 - s), (t - s) / (1.0 - s)
147
- transported = keep[:, None, None] * xs + move[:, None, None] * prediction # X_{s,t}(x).
148
- with torch.no_grad(): # The teacher is the same network read on the diagonal, detached.
149
- teacher_logits = logits_of(model, transported.detach(), t, t, length, vocab)
150
- target = repaired_teacher(teacher_logits, factor.detach())
151
- additive = torch.softmax(teacher_logits, -1) - gamma * prediction * centered(
152
- prediction, dz) # The unrepaired target, measured and not trained on.
153
- counter["negative"] += float((additive < 0).to(torch.get_default_dtype()).mean())
154
- counter["batches"] += 1.0
155
- student = torch.log_softmax(z, dim=-1) # The student in log space.
156
- return anchor + (target * ((target.clamp_min(1e-9)).log() - student)).sum(-1).mean()
157
-
158
- return loss_fn
159
-
160
-
161
- # %% 07. Sampling
162
- @torch.no_grad()
163
- def sample(model, count, length, vocab, steps, device):
164
- """Walk a uniform grid with the convex form and read the alphabet off the last prediction."""
165
- x = torch.randn(count, length, vocab, device=device)
166
- grid = torch.linspace(0.0, 1.0, steps + 1)
167
- for a, b in zip(grid[:-1], grid[1:]):
168
- s, t = a.expand(count).to(device), b.expand(count).to(device)
169
- prediction = torch.softmax(logits_of(model, x, s, t, length, vocab), dim=-1)
170
- keep, move = (1.0 - t) / (1.0 - s), (t - s) / (1.0 - s)
171
- x = keep[:, None, None] * x + move[:, None, None] * prediction
172
- return x.argmax(-1) # [N, L].
173
-
174
-
175
- # %% 08. The report
176
- def main() -> None:
177
- parser = common_parser(__doc__.split("\n")[0])
178
- parser.set_defaults(steps=1500, batch_size=128, samples=64, length=16, sample_steps=4)
179
- args = parser.parse_args()
180
- set_seed(args.seed)
181
- device = pick_device(args.device)
182
- tokens, _ = synthetic_dna(num_sequences=1024, length=args.length, seed=args.seed)
183
- data = tokens.to(device) # [1024, L].
184
-
185
- report = worked_derivative()
186
- report.update(worked_repair())
187
- if not args.quiet:
188
- print(f"derivative target {report['derivative target']} has "
189
- f"{report['negative entries']} negative entries and sums to "
190
- f"{report['target sum']:.4f}")
191
- print(f"additive {report['additive target']} repairs to {report['repaired target']}")
192
-
193
- counter = {"negative": 0.0, "batches": 0.0}
194
- model = TwoTimeMLP(dim=args.length * DNA_SIZE, width=args.width).to(device)
195
- if not args.quiet:
196
- print(f"\ntraining with the repaired target parameters={count_parameters(model):,}")
197
- losses = train(model, make_loss(model, data, args.length, DNA_SIZE, args.batch_size, device,
198
- counter), steps=args.steps, lr=args.lr, quiet=args.quiet)
199
- report["additive target negative share"] = counter["negative"] / max(counter["batches"], 1.0)
200
-
201
- samples: list[str] = []
202
- for steps in (1, args.sample_steps):
203
- torch.manual_seed(args.seed + 1) # The same source noise at every step count.
204
- drawn = sample(model, args.samples, args.length, DNA_SIZE, steps, device)
205
- report[f"{steps}-step GC fraction"] = gc_fraction(drawn)
206
- report[f"{steps}-step motif fraction"] = motif_fraction(drawn)
207
- report[f"{steps}-step token entropy"] = token_entropy(drawn, DNA_SIZE)
208
- report[f"{steps}-step unique fraction"] = unique_fraction(drawn)
209
- samples.append(f"{steps} step(s): {decode_dna(drawn[:1])[0]} GC {gc_fraction(drawn):.3f}")
210
- report["training GC fraction"] = gc_fraction(data)
211
- report["final loss"] = sum(losses[-20:]) / len(losses[-20:])
212
-
213
- print_report("Discrete Flow Maps, Chapter 6", report, samples)
214
- save_run(args.out, model=model, config=vars(args), report=report, losses=losses, samples=samples)
215
-
216
-
217
- if __name__ == "__main__":
218
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_6/expanding.py DELETED
@@ -1,217 +0,0 @@
1
- """Expanding flow maps, where the state gains coordinates as time runs.
2
-
3
- The Jacobian of a map between states of different length is rectangular, so it has no determinant
4
- and no inverse, and Liouville's formula, the exact likelihood and the inverse map all stop
5
- applying. Splitting the move into an expansion that fills new coordinates with source noise and a
6
- transport that acts at the longer length restores a square Jacobian. Each coordinate then carries
7
- its own clock, rescaled to the interval it occupies, and the composition identity is still
8
- available, because a finite difference between two maps needs no derivative in time.
9
-
10
- Chapter 6, Section 6.32 of the notes. Trained on the synthetic DNA of
11
- ``cis6270.data.synthetic_dna``, grown from half its length to all of it. Prints the local clocks
12
- of the chapter's figure, which read 0.800, 0.692 and 0.333 at a global time of 0.8, the
13
- composition weight 0.125 at (0.3, 0.6, 0.9), and samples from the trained map.
14
-
15
- python lecture_6/expanding.py # 1500 steps, about half a minute
16
- python lecture_6/expanding.py --steps 20 # a few seconds, for a first look
17
- """
18
-
19
- # %% 01. Imports and configuration
20
- import math
21
-
22
- import torch
23
-
24
- from cis6270.data import DNA_SIZE, decode_dna, synthetic_dna
25
- from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
26
- from cis6270.nets import TwoTimeMLP, count_parameters
27
- from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
28
-
29
-
30
- torch.set_num_threads(1) # These networks are small, and splitting one matrix product costs more
31
- # than it saves. ``tests/conftest.py`` sets the same thing for the test session, so the line
32
- # here is the one that acts when a reader runs this script directly.
33
-
34
- BOOK_BIRTHS = (0.0, 0.0, 0.35, 0.7) # The insertion times of the chapter's four-tile figure.
35
-
36
-
37
- # %% 02. Local clocks
38
- #
39
- # A coordinate inserted at t_ins has 1 - t_ins of global time left, so its own clock runs at
40
- # 1/(1 - t_ins) and reaches one exactly at the data end. Before its insertion the clock reads
41
- # zero and the coordinate holds its source value.
42
- def local_time(t, birth):
43
- """t_i = (t - t_ins)/(1 - t_ins), clamped to [0, 1] so an unborn coordinate reads zero."""
44
- return ((t - birth) / (1.0 - birth).clamp(min=1e-6)).clamp(0.0, 1.0)
45
-
46
-
47
- def worked_clocks(t: float = 0.8):
48
- """The clocks, the speeds and the interpolant shares of the chapter's figure at one time."""
49
- births = torch.tensor(BOOK_BIRTHS)
50
- clocks = local_time(torch.tensor(t), births) # [4].
51
- speeds = 1.0 / (1.0 - births) # [4] dt_i/dt.
52
- return {
53
- "insertion times": [round(v, 2) for v in births.tolist()],
54
- "local clocks at t=0.8": [round(v, 3) for v in clocks.tolist()],
55
- "clock speeds": [round(v, 3) for v in speeds.tolist()],
56
- "source share": [round(v, 3) for v in (1.0 - clocks).tolist()],
57
- "last clock from its speed": float(speeds[-1] * (1.0 - t)),
58
- "live coordinates at 0.8": int((births <= t).sum()),
59
- "live coordinates at 0.6": int((births <= 0.6).sum()),
60
- }
61
-
62
-
63
- # %% 03. The composition weight under growth
64
- #
65
- # The convexity weight is a ratio of remaining times and makes no reference to the dimension, so
66
- # the derivation at fixed length goes through once every leg has been lifted to the final length.
67
- # A coordinate born at zero carries the chapter's omega; a later birth carries its own.
68
- def omega(s, u, t):
69
- """omega_{s,u,t} = (u-s)(1-t)/((t-s)(1-u)), the share of the earlier leg."""
70
- return (u - s) * (1.0 - t) / (((t - s) * (1.0 - u)).clamp(min=1e-9))
71
-
72
-
73
- def worked_omega(s: float = 0.3, u: float = 0.6, t: float = 0.9):
74
- """The weight at the chapter's triple, for coordinates born at three different times."""
75
- out = {"omega at (0.3, 0.6, 0.9)": float(omega(*(torch.tensor([v]) for v in (s, u, t))))}
76
- for birth in (0.0, 0.35, 0.7):
77
- b = torch.tensor([birth])
78
- local = [local_time(torch.tensor([v]), b) for v in (s, u, t)]
79
- out[f"omega for a birth at {birth}"] = float(omega(*local))
80
- return out
81
-
82
-
83
- # %% 04. A state whose length grows, and where the fresh noise enters
84
- #
85
- # The source values of every coordinate that will ever appear are drawn once, at the final
86
- # length, and a leg that stops before an insertion leaves that coordinate at its source value.
87
- # One draw then serves all three maps of the composition identity.
88
- def lifted_state(source, data_onehot, t, births):
89
- """The state at global time t, each coordinate interpolated on its own clock. [B, L, K]."""
90
- clock = local_time(t[:, None], births[None, :])[:, :, None] # [B, L, 1].
91
- return (1.0 - clock) * source + clock * data_onehot # [B, L, K].
92
-
93
-
94
- def expansion_log_density(source, births, s: float, t: float):
95
- """The source log density of the coordinates inserted inside (s, t], the expansion term."""
96
- inserted = (births > s) & (births <= t) # [L] which coordinates appear on this interval.
97
- per_coordinate = (-0.5 * source ** 2 - 0.5 * math.log(2.0 * math.pi)).sum(-1) # [B, L].
98
- return float(per_coordinate[:, inserted].sum(-1).mean()), int(inserted.sum())
99
-
100
-
101
- # %% 05. The model, a two-time denoiser that also reads each coordinate's clock
102
- def predict(model, x, s, t, births, length, vocab):
103
- """psi_{s,t}(x) at every position, with each coordinate's own clock as an extra channel.
104
-
105
- The network has the same width in and out, so every position carries K + 1 channels, the K
106
- coordinates of the state and the local clock. The clock channel of the output is dropped.
107
- """
108
- clock = local_time(s[:, None], births[None, :])[:, :, None] # [B, L, 1] where each stands.
109
- features = torch.cat([x, clock], dim=-1).flatten(1) # [B, L*(K+1)].
110
- logits = model(features, s, t).view(-1, length, vocab + 1)[..., :vocab] # [B, L, K].
111
- return torch.softmax(logits, dim=-1) # [B, L, K] on the simplex at every position.
112
-
113
-
114
- def step_map(x, prediction, s, t, births):
115
- """The convex form, with the weights read on each coordinate's own clock."""
116
- s_local = local_time(s[:, None], births[None, :])[:, :, None] # [B, L, 1].
117
- t_local = local_time(t[:, None], births[None, :])[:, :, None] # [B, L, 1].
118
- keep = (1.0 - t_local) / (1.0 - s_local).clamp(min=1e-6)
119
- return keep * x + (1.0 - keep) * prediction # [B, L, K].
120
-
121
-
122
- def make_loss(model, data, births, length, vocab, batch_size, device):
123
- eye = torch.eye(vocab, device=device)
124
-
125
- def loss_fn(step: int) -> torch.Tensor:
126
- index = torch.randint(0, len(data), (batch_size,), device=device)
127
- ids = data[index] # [B, L].
128
- x1 = eye[ids] # [B, L, K] the data, lifted.
129
- source = torch.randn(batch_size, length, vocab, device=device) # Drawn once, at full length.
130
- t_diag = torch.rand(batch_size, device=device)
131
- xt = lifted_state(source, x1, t_diag, births)
132
- diagonal = -(predict(model, xt, t_diag, t_diag, births, length, vocab).clamp_min(1e-9)
133
- .log().gather(-1, ids[..., None])).mean() # Cross-entropy on the diagonal.
134
-
135
- s = torch.rand(batch_size, device=device)
136
- t = s + (1.0 - s) * torch.rand(batch_size, device=device)
137
- u = s + (t - s) * torch.rand(batch_size, device=device)
138
- xs = lifted_state(source, x1, s, births) # The same source draw serves all three legs.
139
- with torch.no_grad():
140
- first = predict(model, xs, s, u, births, length, vocab)
141
- second = predict(model, step_map(xs, first, s, u, births), u, t, births, length, vocab)
142
- s_l, u_l, t_l = (local_time(v[:, None], births[None, :]) for v in (s, u, t))
143
- weight = omega(s_l, u_l, t_l)[:, :, None] # [B, L, 1] one weight per coordinate.
144
- target = weight * first + (1.0 - weight) * second # Still on the simplex.
145
- student = predict(model, xs, s, t, births, length, vocab).clamp_min(1e-9)
146
- return diagonal + (target * (target.clamp_min(1e-9) / student).log()).sum(-1).mean()
147
-
148
- return loss_fn
149
-
150
-
151
- # %% 06. Sampling
152
- @torch.no_grad()
153
- def sample(model, count, births, length, vocab, steps, device):
154
- """Walk a uniform grid, with every coordinate stepping on its own clock."""
155
- x = torch.randn(count, length, vocab, device=device) # The source values, drawn once.
156
- grid = torch.linspace(0.0, 1.0, steps + 1)
157
- for a, b in zip(grid[:-1], grid[1:]):
158
- s, t = a.expand(count).to(device), b.expand(count).to(device)
159
- x = step_map(x, predict(model, x, s, t, births, length, vocab), s, t, births)
160
- return x.argmax(-1) # [N, L].
161
-
162
-
163
- # %% 07. The report
164
- def main() -> None:
165
- parser = common_parser(__doc__.split("\n")[0])
166
- parser.set_defaults(steps=1500, batch_size=128, samples=64, length=16, sample_steps=4)
167
- args = parser.parse_args()
168
- set_seed(args.seed)
169
- device = pick_device(args.device)
170
- tokens, _ = synthetic_dna(num_sequences=1024, length=args.length, seed=args.seed)
171
- data = tokens.to(device) # [1024, L] the full-length sequences.
172
-
173
- # Half the coordinates exist at the source; the rest are inserted at evenly spaced times.
174
- half = args.length // 2
175
- births = torch.cat([torch.zeros(half, device=device),
176
- torch.linspace(0.1, 0.8, args.length - half, device=device)]) # [L].
177
-
178
- report = worked_clocks()
179
- report.update(worked_omega())
180
- report["coordinates at the source"] = int((births == 0).sum())
181
- report["coordinates at the data"] = args.length
182
- source = torch.randn(256, args.length, DNA_SIZE)
183
- added, count = expansion_log_density(source, births.cpu(), 0.0, 0.5)
184
- report["coordinates inserted in (0, 0.5]"] = count
185
- report["source log density of those"] = added
186
- if not args.quiet:
187
- print(f"local clocks at t = 0.8: {report['local clocks at t=0.8']} with speeds "
188
- f"{report['clock speeds']}")
189
- print(f"omega at (0.3, 0.6, 0.9) = {report['omega at (0.3, 0.6, 0.9)']:.3f}")
190
-
191
- model = TwoTimeMLP(dim=args.length * (DNA_SIZE + 1), width=args.width).to(device)
192
- if not args.quiet:
193
- print(f"\ntraining the expanding map parameters={count_parameters(model):,}")
194
- losses = train(model, make_loss(model, data, births, args.length, DNA_SIZE, args.batch_size,
195
- device), steps=args.steps, lr=args.lr, quiet=args.quiet)
196
-
197
- samples: list[str] = []
198
- early, late = births == 0, births > 0 # Coordinates born at the source and born later.
199
- for steps in (1, args.sample_steps):
200
- torch.manual_seed(args.seed + 1)
201
- drawn = sample(model, args.samples, births, args.length, DNA_SIZE, steps, device)
202
- report[f"{steps}-step GC fraction"] = gc_fraction(drawn)
203
- report[f"{steps}-step GC, born at the source"] = gc_fraction(drawn[:, early])
204
- report[f"{steps}-step GC, born later"] = gc_fraction(drawn[:, late])
205
- report[f"{steps}-step motif fraction"] = motif_fraction(drawn)
206
- report[f"{steps}-step token entropy"] = token_entropy(drawn, DNA_SIZE)
207
- report[f"{steps}-step unique fraction"] = unique_fraction(drawn)
208
- samples.append(f"{steps} step(s): {decode_dna(drawn[:1])[0]} GC {gc_fraction(drawn):.3f}")
209
- report["training GC fraction"] = gc_fraction(data)
210
- report["final loss"] = sum(losses[-20:]) / len(losses[-20:])
211
-
212
- print_report("Expanding flow maps, Chapter 6", report, samples)
213
- save_run(args.out, model=model, config=vars(args), report=report, losses=losses, samples=samples)
214
-
215
-
216
- if __name__ == "__main__":
217
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_6/flow_map.py DELETED
@@ -1,206 +0,0 @@
1
- """The flow map of an ordinary differential equation, with no learning anywhere.
2
-
3
- A flow map X_{s,t}(x) returns the state at time t of the trajectory that passes through x at time
4
- s, so one evaluation replaces the N Euler steps of Chapter 2. Here we integrate a velocity we
5
- already know, the Gaussian flow of the chapter, and check against it every property the rest of
6
- the lecture trains for: the three axioms, the inverse, the Jacobian and its differential equation,
7
- Liouville's formula, the transport of a density, and the generator.
8
-
9
- Chapter 6, Sections 6.3 to 6.12 of the notes. Runs on the scalar Gaussian flow and on a batch from
10
- ``cis6270.data.gaussian_mixture_2d``. Prints the four numbers of the chapter's listing, 4.100000
11
- for the map, the cocycle agreeing with it, 0.700000 for the inverse and a Jacobian determinant of
12
- 3.000000, together with log 3, the density ratio and the log likelihood.
13
-
14
- python lecture_6/flow_map.py # 200 Runge-Kutta substeps per crossing
15
- python lecture_6/flow_map.py --steps 20 # 20 substeps, a second, and the same report
16
- """
17
-
18
- # %% 01. Imports and configuration
19
- import math
20
-
21
- import torch
22
- from torch.func import jacrev # Differentiates the whole integration in its starting point.
23
-
24
- from cis6270.data import GAUSSIAN_STD, gaussian_mixture_2d
25
- from cis6270.runner import common_parser, print_report, save_run, set_seed
26
-
27
- torch.set_num_threads(1) # The integration is a scalar loop over a small batch, so one thread is
28
- # the fastest here. ``tests/conftest.py`` sets the same thing for the test session, so the line here
29
- # is the one that acts when a reader runs this script directly.
30
- torch.set_default_dtype(torch.float64) # The axioms are checked below the float32 noise floor.
31
-
32
-
33
- SUBSTEPS = 200 # n, the Runge-Kutta substeps per crossing. No optimizer runs in this file.
34
- START = 0.7 # The state x at which the chapter evaluates every check.
35
-
36
-
37
- # %% 02. The velocity field of the Gaussian flow
38
- #
39
- # Source N(0,1) and data N(2,9), paired by x_1 = 2 + 3 x_0 and joined by a straight line. The
40
- # velocity is a translation at speed 2 plus an expansion whose strength 2/(1+2t) decays as the
41
- # cloud widens.
42
- def b(x, t): # velocity field
43
- return 2.0 + 2.0 * (x - 2.0 * t) / (1.0 + 2.0 * t) # Section 6.9, b(x,t).
44
-
45
-
46
- def exact_map(x, s, t):
47
- """X_{s,t}(x) = 2t + (1+2t)(x - 2s)/(1 + 2s), the closed form the integrator has to reproduce."""
48
- return 2.0 * t + (1.0 + 2.0 * t) * (x - 2.0 * s) / (1.0 + 2.0 * s) # [B] or scalar.
49
-
50
-
51
- # %% 03. The flow map by fourth-order Runge-Kutta
52
- #
53
- # Four velocity evaluations per substep, at its start, twice at its midpoint and at its end,
54
- # combined with the weights 1, 2, 2, 1 over six. The step h is negative when t is below s, so the
55
- # same function integrates backward with no change.
56
- def flow(x, s, t, n=SUBSTEPS): # X_{s,t}(x) by RK4
57
- h = (t - s) / n
58
- for k in range(n):
59
- u = s + k * h
60
- k1 = b(x, u)
61
- k2 = b(x + 0.5 * h * k1, u + 0.5 * h)
62
- k3 = b(x + 0.5 * h * k2, u + 0.5 * h)
63
- k4 = b(x + h * k3, u + h)
64
- x = x + (h / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
65
- return x
66
-
67
-
68
- # %% 04. The three axioms, checked numerically
69
- #
70
- # Identity, cocycle and tangent characterize the map (Proposition 6.1). The first holds by
71
- # definition, the second by uniqueness of the trajectory, the third by the defining equation.
72
- def check_axioms(x, n):
73
- """Return the residual of each axiom at the state x, each of which the true map drives to zero."""
74
- identity = flow(x, 0.5, 0.5, n) - x # X_{t,t} = id.
75
- cocycle = flow(flow(x, 0.0, 0.5, n), 0.5, 1.0, n) - flow(x, 0.0, 1.0, n) # X_{t,u} o X_{s,t}.
76
- h = 1e-6 # One-sided difference quotient at the diagonal.
77
- tangent = (flow(x, 0.5, 0.5 + h, n) - x) / h - b(x, 0.5) # d/dt X at t = s equals b(x,s).
78
- return float(identity.abs()), float(cocycle.abs()), float(tangent.abs())
79
-
80
-
81
- # %% 05. The Jacobian and the equation it satisfies
82
- #
83
- # dJ/dt = grad b(X_{s,t}(x), t) J, starting from J_{s,s} = I. In one dimension grad b is the single
84
- # derivative 2/(1+2t), and the solution from zero is J_{0,t} = 1 + 2t.
85
- def jacobian_by_ode(x, s, t, n):
86
- """Integrate the state and its Jacobian together, with one Euler step each on the same grid."""
87
- jacobian = torch.ones_like(x) # J_{s,s} = I, the identity axiom differentiated in x.
88
- h = (t - s) / n
89
- for k in range(n):
90
- u = s + k * h
91
- grad_b = 2.0 / (1.0 + 2.0 * u) # d b / d x at time u, the divergence in one dimension.
92
- jacobian = jacobian + h * grad_b * jacobian # Linear in J, with the coefficient frozen.
93
- x = x + h * b(x, u) # The trajectory the coefficient is read along.
94
- return jacobian
95
-
96
-
97
- def liouville_log_det(s, t, n):
98
- """Integrate d/dt log det J = div b, which on this flow is the number log 3 over [0, 1]."""
99
- h = (t - s) / n
100
- grid = s + h * (torch.arange(n) + 0.5) # Midpoints of the substeps.
101
- return float((2.0 / (1.0 + 2.0 * grid)).sum() * h) # Riemann sum of the divergence.
102
-
103
-
104
- # %% 06. Transporting a density along the flow
105
- #
106
- # p_t(X_{s,t}(x)) |det J_{s,t}(x)| = p_s(x). The flow stretches every length by 3 over the unit
107
- # interval, so it divides every density by 3.
108
- def normal_density(x, mean, std):
109
- """The scalar Gaussian density, used at both ends of the transport statement."""
110
- return math.exp(-((x - mean) ** 2) / (2.0 * std ** 2)) / (std * math.sqrt(2.0 * math.pi))
111
-
112
-
113
- def mixture_log_density_2d(points, scale, shift):
114
- """Log density of the four-Gaussian mixture after the affine map y = shift + scale * x.
115
-
116
- The pushforward of a Gaussian mixture under an affine map is a Gaussian mixture with the
117
- centers and the widths carried through, which gives the transported density in closed form.
118
- """
119
- from cis6270.data import GAUSSIAN_CENTERS
120
-
121
- centers = shift + scale * GAUSSIAN_CENTERS.to(points) # [4, 2] transported centers.
122
- std = scale * GAUSSIAN_STD # Each component is widened by the same factor.
123
- squared = ((points[:, None, :] - centers) ** 2).sum(dim=-1) # [N, 4].
124
- component = -0.5 * squared / std ** 2 - math.log(2.0 * math.pi * std ** 2) # [N, 4].
125
- return torch.logsumexp(component + math.log(0.25), dim=-1) # [N] mixture log density.
126
-
127
-
128
- # %% 07. The generator of the flow
129
- #
130
- # (L_t f)(x) = b(x,t) . grad f(x), and the rate of change of f along a trajectory is the generator
131
- # applied to f and read at the moving point.
132
- def generator(f, grad_f, x, t):
133
- """(L_t f)(x) for a scalar test function, which in one dimension is b times the derivative."""
134
- return b(x, t) * grad_f(x) # Section 6.11, the generator.
135
-
136
-
137
- # %% 08. The report
138
- def main() -> None:
139
- parser = common_parser(__doc__.split("\n")[0])
140
- parser.set_defaults(steps=SUBSTEPS, samples=512)
141
- args = parser.parse_args() # --steps is the substep count n here, since nothing is trained.
142
- set_seed(args.seed)
143
- n = max(args.steps, 4) # Runge-Kutta needs a handful of substeps to be worth its name.
144
-
145
- # %% 09. The chapter's four lines
146
- x = torch.tensor(START) # The single starting point every check below uses.
147
- one_crossing = flow(x, 0.0, 1.0, n) # 2 + 3x = 4.1.
148
- two_crossings = flow(flow(x, 0.0, 0.5, n), 0.5, 1.0, n) # The cocycle.
149
- there_and_back = flow(flow(x, 0.0, 1.0, n), 1.0, 0.0, n) # The inverse.
150
- det_jacobian = jacrev(lambda z: flow(z, 0.0, 1.0, n))(x) # det J = 3.
151
- if not args.quiet:
152
- print(f"X_0,1({START}) = {float(one_crossing):.6f} closed form "
153
- f"{float(exact_map(x, 0.0, 1.0)):.6f}")
154
- print(f"cocycle through 0.5 = {float(two_crossings):.6f}")
155
- print(f"inverse X_1,0 o X_0,1 = {float(there_and_back):.6f}")
156
- print(f"det J_0,1 = {float(det_jacobian):.6f}")
157
-
158
- identity, cocycle, tangent = check_axioms(x, n)
159
-
160
- # %% 10. The Jacobian two ways, and Liouville's formula
161
- jacobian_ode = jacobian_by_ode(x, 0.0, 1.0, 4000) # One Euler solve of dJ/dt = grad b . J.
162
- log_det = liouville_log_det(0.0, 1.0, 4000) # Integral of the divergence over [0, 1].
163
-
164
- # %% 11. Transport, in one dimension and in two
165
- image = float(one_crossing) # X_{0,1}(0.7) = 4.1.
166
- p_source = normal_density(START, 0.0, 1.0) # p_0(0.7).
167
- p_data = normal_density(image, 2.0, 3.0) # p_1(4.1).
168
- log_likelihood = math.log(p_source) - log_det # Section 6.10, with the divergence integral.
169
-
170
- points = gaussian_mixture_2d(num_points=args.samples, seed=args.seed) # [N, 2] source cloud.
171
- moved = exact_map(points, 0.0, 1.0) # The same affine flow applied to both coordinates.
172
- log_p0 = mixture_log_density_2d(points, 1.0, 0.0) # [N] density before transport.
173
- log_p1 = mixture_log_density_2d(moved, 3.0, 2.0) # [N] density of the transported mixture.
174
- transport_gap = float((log_p1 + 2.0 * math.log(3.0) - log_p0).abs().max()) # det J = 3^2 = 9.
175
-
176
- # %% 12. The generator along one trajectory
177
- f, grad_f = (lambda z: z ** 2), (lambda z: 2.0 * z) # A test function and its derivative.
178
- h = 1e-5
179
- along = (f(flow(x, 0.0, 0.5 + h, n)) - f(flow(x, 0.0, 0.5 - h, n))) / (2.0 * h) # d/dt f(X).
180
- generator_gap = abs(float(along) - float(generator(f, grad_f, flow(x, 0.0, 0.5, n), 0.5)))
181
-
182
- report = {
183
- "X_0,1(0.7)": float(one_crossing),
184
- "cocycle through u=0.5": float(two_crossings),
185
- "inverse back to the start": float(there_and_back),
186
- "det J_0,1 by jacrev": float(det_jacobian),
187
- "det J_0,1 by its own ODE": float(jacobian_ode),
188
- "log det J_0,1 by Liouville": log_det,
189
- "identity axiom residual": identity,
190
- "cocycle axiom residual": cocycle,
191
- "tangent axiom residual": tangent,
192
- "p_0(0.7)": p_source,
193
- "p_1(4.1)": p_data,
194
- "density ratio p_0/p_1": p_source / p_data,
195
- "log p_1(4.1) by transport": log_likelihood,
196
- "log p_1(4.1) directly": math.log(p_data),
197
- "2-D transport gap": transport_gap,
198
- "generator gap": generator_gap,
199
- "substeps n": n,
200
- }
201
- print_report("Flow map of the Gaussian flow, Chapter 6", report)
202
- save_run(args.out, config=vars(args), report=report)
203
-
204
-
205
- if __name__ == "__main__":
206
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_6/fmlm.py DELETED
@@ -1,291 +0,0 @@
1
- """Flow Map Language Models, the composition identity in denoiser coordinates.
2
-
3
- Each token is lifted to a one-hot vector, the source is Gaussian over that space, and the state
4
- moves through R^{L x K}, so no atom has to be split. Stepping by the full remaining time 1 - s in
5
- place of the elapsed time t - s turns the average velocity into a two-time denoiser that lands on
6
- the simplex, the map becomes a convex combination of staying put and jumping to the prediction,
7
- and the cocycle becomes a convex combination of two shorter predictions, which a KL divergence
8
- can measure.
9
-
10
- Chapter 6, Sections 6.25 to 6.27 of the notes. Trained on the synthetic DNA of
11
- ``cis6270.data.synthetic_dna``. Prints the convex weights, the composition weight gamma = 0.2 at
12
- (0.2, 0.5, 0.8), the worked KL of 0.030747 nats, the two gradients that decide the loss, the
13
- decoding clock of a 30,522-token vocabulary and the four steps it produces, and samples from the
14
- trained map.
15
-
16
- python lecture_6/fmlm.py # 1500 steps, about half a minute
17
- python lecture_6/fmlm.py --steps 20 # a few seconds, for a first look
18
- """
19
-
20
- # %% 01. Imports and configuration
21
- import math
22
-
23
- import torch
24
-
25
- from cis6270.data import DNA_SIZE, decode_dna, synthetic_dna
26
- from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
27
- from cis6270.nets import TwoTimeMLP, count_parameters
28
- from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
29
-
30
-
31
- torch.set_num_threads(1) # These networks are small, and splitting one matrix product costs more
32
- # than it saves. ``tests/conftest.py`` sets the same thing for the test session, so the line
33
- # here is the one that acts when a reader runs this script directly.
34
-
35
- BOOK_VOCAB = 30522 # The vocabulary the chapter puts on the clock, a standard tokenizer.
36
- ENDPOINT_FRACTION = 0.25 # Share of each batch drawn with t = 1, the slice the sampler reads.
37
-
38
-
39
- # %% 02. The convex form and the composition weight
40
- #
41
- # X_{s,t}(x) = (1-t)/(1-s) x + (t-s)/(1-s) delta_{s,t}(x). Both weights are non-negative for
42
- # s <= t <= 1 and they sum to one, so each step is a convex combination of staying put and jumping
43
- # to the predicted endpoint. At t = s the pair is (1, 0) and the identity axiom holds.
44
- def convex_weights(s, t):
45
- """The pair ((1-t)/(1-s), (t-s)/(1-s)) that the step mixes the state and the prediction with."""
46
- remaining = (1.0 - s).clamp(min=1e-6)
47
- return (1.0 - t) / remaining, (t - s) / remaining
48
-
49
-
50
- def composition_weight(s, u, t):
51
- """gamma = (1-t)(u-s)/((1-u)(t-s)), the share of the long prediction made over the first leg."""
52
- return (1.0 - t) * (u - s) / (((1.0 - u) * (t - s)).clamp(min=1e-6))
53
-
54
-
55
- # %% 03. The cocycle in denoiser coordinates, checked on the scalar flow
56
- #
57
- # delta_{s,t}(x) = gamma delta_{s,u}(x) + (1-gamma) delta_{u,t}(X_{s,u}(x)). On dx/dt = x the
58
- # two-time denoiser is x[1 + (1-s)(e^{t-s}-1)/(t-s)], so every term is available in closed form.
59
- def scalar_denoiser(x, s, t):
60
- """delta_{s,t}(x) on the flow dx/dt = x."""
61
- return x * (1.0 + (1.0 - s) * math.expm1(t - s) / (t - s))
62
-
63
-
64
- def scalar_cocycle(x: float = 1.0, s: float = 0.2, u: float = 0.5, t: float = 0.8):
65
- """Every term of the denoiser cocycle at the chapter's triple of times."""
66
- gamma = (1.0 - t) * (u - s) / ((1.0 - u) * (t - s))
67
- first = scalar_denoiser(x, s, u) # The prediction over the first leg.
68
- reached = x * math.exp(u - s) # X_{s,u}(x), the state the first leg arrives at.
69
- second = scalar_denoiser(reached, u, t) # The prediction over the second leg.
70
- long = scalar_denoiser(x, s, t) # The prediction over the whole interval.
71
- keep, move = (1.0 - t) / (1.0 - s), (t - s) / (1.0 - s)
72
- return {"gamma": gamma, "delta_s,u": first, "delta_u,t": second, "delta_s,t": long,
73
- "two legs combined": gamma * first + (1.0 - gamma) * second,
74
- "convex weights": (round(keep, 4), round(move, 4)),
75
- "convex form": keep * x + move * long, "X_s,t(x)": x * math.exp(t - s)}
76
-
77
-
78
- # %% 04. One term of the loss, on the DNA alphabet
79
- #
80
- # The composed target is a convex combination of two simplex points, so it is a distribution and
81
- # the divergence against it needs no projection. Its gradient pushes mass onto the token with more
82
- # weight in the two-leg decomposition than in the long prediction.
83
- def worked_kl():
84
- """The chapter's four-token example, with the target composed from two student predictions."""
85
- first = torch.tensor([0.10, 0.20, 0.60, 0.10]) # delta_{0.2,0.5} over (A, C, G, T).
86
- second = torch.tensor([0.05, 0.10, 0.80, 0.05]) # delta_{0.5,0.8} at the state the leg reaches.
87
- gamma = (1.0 - 0.8) * (0.5 - 0.2) / ((1.0 - 0.5) * (0.8 - 0.2))
88
- target = gamma * first + (1.0 - gamma) * second # [4] the composed target.
89
- student = torch.tensor([0.10, 0.15, 0.65, 0.10]) # The student's long prediction.
90
- terms = target * (target / student).log() # [4] the four terms of the divergence.
91
- return {"worked gamma": gamma, "composed target": [round(v, 4) for v in target.tolist()],
92
- "target mass": float(target.sum()), "worked KL": float(terms.sum()),
93
- "the four terms": [round(v, 6) for v in terms.tolist()]}
94
-
95
-
96
- # %% 05. The two gradients a softmax head sees
97
- #
98
- # With a softmax head the KL gradient in the logits is q - p, so the size of the correction in
99
- # coordinate i is the error in coordinate i. The squared error passes through the softmax Jacobian
100
- # q_j(delta_ij - q_i) and carries a factor q_i in every term, so a token the model has learned to
101
- # rule out gets almost no correction. Both gradients are taken by automatic differentiation here,
102
- # which checks the two expressions of Section 6.27 as well as evaluating them.
103
- def logit_gradients(target, student):
104
- """The gradient of KL(p || q) and of half the squared error in the logits of q. Two [K]."""
105
- for_kl = student.log().clone().requires_grad_(True) # Logits whose softmax is the student.
106
- divergence = (target * (target / torch.softmax(for_kl, -1)).log()).sum()
107
- kl_gradient, = torch.autograd.grad(divergence, for_kl) # [K] equal to q - p.
108
- for_squared = student.log().clone().requires_grad_(True) # A second graph on the same logits.
109
- squared = 0.5 * ((torch.softmax(for_squared, -1) - target) ** 2).sum()
110
- squared_gradient, = torch.autograd.grad(squared, for_squared) # [K] with a q_i throughout.
111
- return kl_gradient, squared_gradient
112
-
113
-
114
- def gradient_comparison(floor: float = 1e-6):
115
- """The two gradients at the chapter's student, and again once that student has saturated."""
116
- target = torch.tensor([0.06, 0.12, 0.76, 0.06]) # p, the composed target of Section 6.27.
117
- student = torch.tensor([0.10, 0.15, 0.65, 0.10]) # q, the student's long prediction.
118
- kl, squared = logit_gradients(target, student)
119
- ratio = (squared / kl).abs() # Between a quarter and a half, coordinate by coordinate.
120
- rest = student[1:] * (1.0 - floor) / student[1:].sum() # The other three keep their shares.
121
- saturated = torch.cat([torch.full_like(student[:1], floor), rest]) # [K], summing to one.
122
- kl_low, squared_low = logit_gradients(target, saturated)
123
- return {
124
- "KL gradient": [round(v, 5) for v in kl.tolist()],
125
- "squared-error gradient": [round(v, 5) for v in squared.tolist()],
126
- "squared over KL": [round(v, 4) for v in ratio.tolist()],
127
- "saturated student": [round(v, 6) for v in saturated.tolist()],
128
- "saturated KL gradient": float(kl_low[0]),
129
- # The surviving squared-error gradient is of order 1e-08, which four printed decimals
130
- # cannot show, so the report carries it in units of 1e-08.
131
- "saturated squared-error gradient, in 1e-08": 1e8 * float(squared_low[0]),
132
- "orders of magnitude between them": math.log10(
133
- abs(float(kl_low[0])) / abs(float(squared_low[0]))),
134
- }
135
-
136
-
137
- # %% 06. The decoding clock
138
- #
139
- # A position decodes correctly when its data coordinate is the largest of the |V| coordinates,
140
- # a shifted standard normal against the maximum of the rest. The reparameterized time tau is the
141
- # fraction of the initial decoding error already removed, and it is the clock the sampler uses.
142
- def decoding_error(t: float, vocab_size: int, nodes: int = 4001) -> float:
143
- """P_e(t), the probability that one position decodes to the wrong token at time t."""
144
- if t >= 1.0:
145
- return 0.0
146
- shift = t / (1.0 - t) # a = t/(1-t), the gap the data coordinate opens.
147
- z = torch.linspace(-12.0, 14.0, nodes, dtype=torch.float64) # Quadrature grid over the draw.
148
- density = torch.exp(-0.5 * z * z) / math.sqrt(2.0 * math.pi) # phi(z).
149
- cumulative = 0.5 * torch.erfc(-(z + shift) / math.sqrt(2.0)) # Phi(z + a).
150
- log_all = (vocab_size - 1) * cumulative.clamp(min=1e-300).log() # All the others fall short.
151
- return 1.0 - float(torch.trapz(density * log_all.exp(), z))
152
-
153
-
154
- def clock(vocab_size: int):
155
- """tau(t) = (P_e(0) - P_e(t))/P_e(0), as a callable, with the source error rate beside it."""
156
- start = decoding_error(0.0, vocab_size)
157
- return (lambda t: (start - decoding_error(t, vocab_size)) / start), start
158
-
159
-
160
- def even_in_tau(vocab_size: int, pieces: int = 4):
161
- """Solve tau(t) = k/pieces by bisection and return the grid with its two endpoints."""
162
- tau, _ = clock(vocab_size)
163
- grid = [0.0]
164
- for k in range(1, pieces):
165
- low, high = 0.0, 1.0 - 1e-6
166
- for _ in range(60): # Bisection; tau is increasing in t.
167
- middle = 0.5 * (low + high)
168
- low, high = (middle, high) if tau(middle) < k / pieces else (low, middle)
169
- grid.append(0.5 * (low + high))
170
- return grid + [1.0]
171
-
172
-
173
- # %% 07. The model, a two-time denoiser with a softmax head
174
- def predict(model, x, s, t, length, vocab):
175
- """delta_{s,t}(x) at every position, a point of the simplex produced by a softmax head."""
176
- logits = model(x.flatten(1), s, t).view(-1, length, vocab) # [B, L, K] the network output.
177
- return torch.softmax(logits, dim=-1) # [B, L, K] on the simplex at every position.
178
-
179
-
180
- def step_map(x, prediction, s, t):
181
- """The convex form, which is the flow map written in denoiser coordinates."""
182
- keep, move = convex_weights(s, t)
183
- return keep[:, None, None] * x + move[:, None, None] * prediction # [B, L, K].
184
-
185
-
186
- def make_loss(model, data, length, vocab, batch_size, device):
187
- eye = torch.eye(vocab, device=device) # The one-hot lift of the alphabet.
188
-
189
- def loss_fn(step: int) -> torch.Tensor:
190
- index = torch.randint(0, len(data), (batch_size,), device=device)
191
- x1 = eye[data[index]] # [B, L, K] the data, lifted.
192
- x0 = torch.randn_like(x1) # [B, L, K] the Gaussian source.
193
- t_diag = torch.rand(batch_size, device=device) # [B] a time for the diagonal term.
194
- xt = (1 - t_diag[:, None, None]) * x0 + t_diag[:, None, None] * x1 # [B, L, K].
195
- diagonal = -(predict(model, xt, t_diag, t_diag, length, vocab).clamp_min(1e-9).log()
196
- .gather(-1, data[index][..., None])).mean() # Cross-entropy to the token.
197
-
198
- s = torch.rand(batch_size, device=device) # [B] start time.
199
- t = s + (1.0 - s) * torch.rand(batch_size, device=device) # [B] end time.
200
- # The slice t = 1 is the one-step case the sampler reads, and a continuous draw never
201
- # lands on it, so a fixed share of the batch is set there on purpose (Section 6.26).
202
- endpoint = torch.rand(batch_size, device=device) < ENDPOINT_FRACTION # [B] which rows.
203
- t = torch.where(endpoint, torch.ones_like(t), t)
204
- u = s + (t - s) * torch.rand(batch_size, device=device) # [B] the split point.
205
- xs = (1 - s[:, None, None]) * x0 + s[:, None, None] * x1 # [B, L, K] the interpolant.
206
- with torch.no_grad(): # Both legs and the intermediate state are frozen targets.
207
- first = predict(model, xs, s, u, length, vocab)
208
- second = predict(model, step_map(xs, first, s, u), u, t, length, vocab)
209
- gamma = composition_weight(s, u, t)[:, None, None]
210
- target = gamma * first + (1.0 - gamma) * second # [B, L, K], still on the simplex.
211
- student = predict(model, xs, s, t, length, vocab).clamp_min(1e-9)
212
- off_diagonal = (target * (target.clamp_min(1e-9) / student).log()).sum(-1).mean()
213
- return diagonal + off_diagonal
214
-
215
- return loss_fn
216
-
217
-
218
- # %% 08. Sampling on a grid of times
219
- @torch.no_grad()
220
- def sample(model, count, length, vocab, grid, device):
221
- """Walk the grid with the convex form, and read the alphabet off the last prediction."""
222
- x = torch.randn(count, length, vocab, device=device) # [N, L, K] the Gaussian source.
223
- for a, b in zip(grid[:-1], grid[1:]):
224
- s = torch.full((count,), a, device=device)
225
- t = torch.full((count,), b, device=device)
226
- x = step_map(x, predict(model, x, s, t, length, vocab), s, t)
227
- return x.argmax(-1) # [N, L] token ids, read by an argmax at every position.
228
-
229
-
230
- # %% 09. The report
231
- def main() -> None:
232
- parser = common_parser(__doc__.split("\n")[0])
233
- parser.set_defaults(steps=1500, batch_size=128, samples=64, length=16)
234
- args = parser.parse_args()
235
- set_seed(args.seed)
236
- device = pick_device(args.device)
237
- tokens, _ = synthetic_dna(num_sequences=1024, length=args.length, seed=args.seed)
238
- data = tokens.to(device) # [1024, L] the training sequences.
239
-
240
- report = scalar_cocycle()
241
- report.update(worked_kl())
242
- report.update(gradient_comparison())
243
- if not args.quiet:
244
- print(f"at the saturated student the KL gradient is "
245
- f"{report['saturated KL gradient']:.4f} and the squared-error gradient "
246
- f"{1e-8 * report['saturated squared-error gradient, in 1e-08']:.3e}")
247
- book_tau, book_start = clock(BOOK_VOCAB)
248
- for t in (0.60, 0.75, 0.80, 0.85):
249
- report[f"tau({t:.2f}) at |V|=30522"] = book_tau(t)
250
- book_grid = even_in_tau(BOOK_VOCAB)
251
- report["four steps on the clock"] = [round(t, 6) for t in book_grid]
252
- rounded = [round(t, 3) for t in book_grid] # The chapter quotes the grid to three decimals.
253
- report["convex weights on that grid"] = [
254
- (round((1 - b) / (1 - a), 4), round((b - a) / (1 - a), 4))
255
- for a, b in zip(rounded[:-1], rounded[1:])]
256
- report["gamma on the last three"] = float(composition_weight(
257
- *(torch.tensor([v]) for v in rounded[1:4])))
258
- report["gamma over the long first leg"] = float(composition_weight(
259
- *(torch.tensor([v]) for v in (0.0, rounded[1], rounded[2]))))
260
- report["tau on a uniform grid"] = [round(book_tau(t), 5) for t in (0.25, 0.5, 0.75)]
261
- if not args.quiet:
262
- print(f"clock at |V|=30522: P_e(0) = {book_start:.6f}, four steps at "
263
- f"{[round(t, 3) for t in book_grid]}")
264
-
265
- model = TwoTimeMLP(dim=args.length * DNA_SIZE, width=args.width).to(device)
266
- if not args.quiet:
267
- print(f"\ntraining the two-time denoiser parameters={count_parameters(model):,}")
268
- losses = train(model, make_loss(model, data, args.length, DNA_SIZE, args.batch_size, device),
269
- steps=args.steps, lr=args.lr, quiet=args.quiet)
270
-
271
- dna_grid = even_in_tau(DNA_SIZE) # The same clock, read at the four-letter alphabet.
272
- report["DNA clock grid"] = [round(t, 4) for t in dna_grid]
273
- samples: list[str] = []
274
- for name, grid in (("one step", [0.0, 1.0]), ("four, uniform", [0.0, 0.25, 0.5, 0.75, 1.0]),
275
- ("four, on the clock", dna_grid)):
276
- torch.manual_seed(args.seed + 1) # The same source noise for every schedule.
277
- drawn = sample(model, args.samples, args.length, DNA_SIZE, grid, device)
278
- report[f"{name}: GC fraction"] = gc_fraction(drawn)
279
- report[f"{name}: motif fraction"] = motif_fraction(drawn)
280
- report[f"{name}: token entropy"] = token_entropy(drawn, DNA_SIZE)
281
- report[f"{name}: unique fraction"] = unique_fraction(drawn)
282
- samples.append(f"{name:<20s} {decode_dna(drawn[:1])[0]} GC {gc_fraction(drawn):.3f}")
283
- report["training GC fraction"] = gc_fraction(data)
284
- report["final loss"] = sum(losses[-20:]) / len(losses[-20:])
285
-
286
- print_report("Flow Map Language Models, Chapter 6", report, samples)
287
- save_run(args.out, model=model, config=vars(args), report=report, losses=losses, samples=samples)
288
-
289
-
290
- if __name__ == "__main__":
291
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_6/meanflow.py DELETED
@@ -1,175 +0,0 @@
1
- """MeanFlow, the Eulerian identity written in the average-velocity parameterization.
2
-
3
- Substituting X_{s,t}(x) = x + (t-s) v_{s,t}(x) into the Eulerian identity leaves one equation,
4
- v_{s,t}(x) = b(x,s) + (t-s) d/ds v_{s,t}(x), whose correction term is a total derivative along the
5
- trajectory. One forward-mode pass along the tangent (b, 1, 0) in the arguments (x, s, t) computes
6
- that derivative without forming a Jacobian, and the target uses the realized velocity x_1 - x_0 of
7
- the interpolant in place of b, so a step costs one forward pass and one derivative.
8
-
9
- Chapter 6, Sections 6.13, 6.15 and 6.18 of the notes. Trained on the four Gaussian blobs of
10
- ``cis6270.data.gaussian_mixture_2d``. Prints the identity worked by hand on dx/dt = x, where the
11
- average velocity is 1.718x, the instantaneous velocity is x and the correction is 0.718x, and then
12
- one-step, two-step and four-step samples from the trained map.
13
-
14
- python lecture_6/meanflow.py # 2000 steps, about twenty seconds
15
- python lecture_6/meanflow.py --steps 20 # a few seconds, for a first look
16
- """
17
-
18
- # %% 01. Imports and configuration
19
- import math
20
-
21
- import torch
22
-
23
- from cis6270.data import gaussian_mixture_2d
24
- from cis6270.metrics import mean_log_density, mode_report
25
- from cis6270.nets import TwoTimeMLP, count_parameters
26
- from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
27
-
28
-
29
- torch.set_num_threads(1) # These networks are small, and splitting one matrix product costs more
30
- # than it saves. ``tests/conftest.py`` sets the same thing for the test session, so the line
31
- # here is the one that acts when a reader runs this script directly.
32
-
33
- DIAGONAL_FRACTION = 0.25 # Share of each batch drawn with t = s, where the identity is flow matching.
34
-
35
-
36
- # %% 02. The average velocity of the scalar flow, in closed form
37
- #
38
- # The exact map of dx/dt = x multiplies the state by e^{t-s}, so the displacement over the elapsed
39
- # time is x (e^{t-s} - 1)/(t-s). On the diagonal the limit is x, which is b(x,t).
40
- def scalar_average_velocity(x, s, t):
41
- """v_{s,t}(x) = x (e^{t-s} - 1)/(t - s) on the flow dx/dt = x."""
42
- elapsed = t - s
43
- return x * torch.expm1(elapsed) / elapsed # expm1 keeps the limit accurate near the diagonal.
44
-
45
-
46
- # %% 03. The identity, checked on the scalar flow by hand
47
- #
48
- # v_{s,t}(x) = b(x,s) + (t-s) d/ds v_{s,t}(x), with d/ds = partial_s + b(x,s) . grad_x. At s = 0,
49
- # t = 1 the partial derivative is -x, the spatial gradient is e - 1, and the transport term is
50
- # therefore (e-1)x, so the correction is x(e-2) = 0.718x.
51
- def scalar_check(x_value: float = 1.0):
52
- """Return every term of the identity at s = 0, t = 1, each one computed and not quoted."""
53
- x = torch.tensor(x_value)
54
- s, t = torch.tensor(0.0), torch.tensor(1.0)
55
- average = scalar_average_velocity(x, s, t) # v_{0,1}(x) = x(e-1).
56
- instant = x # b(x, 0) = x on this flow.
57
- partial_s = torch.func.jvp( # Derivative in the start time alone, with x and t held fixed.
58
- lambda ss: scalar_average_velocity(x, ss, t), (s,), (torch.ones_like(s),))[1]
59
- gradient_x = torch.func.jvp( # Derivative in the state alone, the other half of d/ds.
60
- lambda xx: scalar_average_velocity(xx, s, t), (x,), (torch.ones_like(x),))[1]
61
- transport = instant * gradient_x # b(x,s) . grad_x v, the second piece of the total derivative.
62
- total = partial_s + transport # d/ds v_{s,t}(x) = x(e - 2).
63
- return {
64
- "v_0,1(1)": float(average),
65
- "b(1, 0)": float(instant),
66
- "partial_s v": float(partial_s),
67
- "grad_x v_0,1": float(gradient_x),
68
- "d/ds v_0,1": float(total),
69
- "identity gap": float((average - (instant + (t - s) * total)).abs()),
70
- "one map evaluation": float(x + (t - s) * average),
71
- "one Euler step": float(x + (t - s) * instant),
72
- "e": math.e,
73
- }
74
-
75
-
76
- # %% 04. The Gaussian flow, where the correction vanishes
77
- #
78
- # A linear interpolant between paired Gaussians produces straight trajectories, and on a straight
79
- # trajectory the average velocity equals the initial velocity, so the total derivative is zero.
80
- def gaussian_check(x_value: float = 0.7):
81
- """v_{0,t}(x) = 2 + 2x for every end time, and b(x,0) is the same number."""
82
- x = torch.tensor(x_value)
83
- average = torch.stack([ # Three end times, all giving the same average velocity.
84
- (2.0 * t + (1.0 + 2.0 * t) * x - x) / t for t in (0.25, 0.5, 1.0)])
85
- instant = 2.0 + 2.0 * (x - 0.0) / 1.0 # b(x, 0) on the Gaussian flow.
86
- return {"v_0,t(0.7) spread": float(average.max() - average.min()),
87
- "v_0,1(0.7)": float(average[-1]), "b(0.7, 0)": float(instant)}
88
-
89
-
90
- # %% 05. The MeanFlow target, by one forward-mode pass
91
- #
92
- # The tangent (x_1 - x_0, 1, 0) in the arguments (x, s, t) contracts the full Jacobian of v into
93
- # the total derivative along the trajectory: the realized velocity in the state slot, one in the
94
- # start-time slot, and zero in the end-time slot because the end time is held fixed.
95
- def mean_flow_target(model, xs, s, t, realized):
96
- """b(x,s) + (t-s) d/ds v_{s,t}(x), detached, which is the right-hand side of the identity."""
97
- f = lambda xx, ss: model(xx, ss, t)
98
- _, total = torch.func.jvp(f, (xs, s), (realized, torch.ones_like(s))) # [B, d] d/ds v.
99
- return (realized + (t - s)[:, None] * total).detach() # [B, d] the target, held fixed.
100
-
101
-
102
- def make_loss(model, data, batch_size, device):
103
- """Draw a coupling, draw times from the triangle, and regress v onto its own identity."""
104
-
105
- def loss_fn(step: int) -> torch.Tensor:
106
- index = torch.randint(0, len(data), (batch_size,), device=device)
107
- x1 = data[index] # [B, 2] the data draw.
108
- x0 = torch.randn_like(x1) # [B, 2] the source draw.
109
- s = torch.rand(batch_size, device=device) # [B] start time.
110
- t = s + (1.0 - s) * torch.rand(batch_size, device=device) # [B] end time, s <= t <= 1.
111
- diagonal = torch.rand(batch_size, device=device) < DIAGONAL_FRACTION # [B] which rows anchor.
112
- t = torch.where(diagonal, s, t) # On those rows the correction term has a factor of zero.
113
- xs = (1 - s[:, None]) * x0 + s[:, None] * x1 # [B, 2] the interpolant I_s.
114
- realized = x1 - x0 # [B, 2] the derivative of the interpolant, available in closed form.
115
- target = mean_flow_target(model, xs, s, t, realized)
116
- return ((model(xs, s, t) - target) ** 2).mean()
117
-
118
- return loss_fn
119
-
120
-
121
- # %% 06. Sampling, at any step count the reader chooses
122
- @torch.no_grad()
123
- def sample(model, x0, steps: int):
124
- """Cross [0, 1] in equal pieces, one evaluation of the map per piece."""
125
- x = x0
126
- grid = torch.linspace(0.0, 1.0, steps + 1, device=x0.device) # [steps+1] the time grid.
127
- for s, t in zip(grid[:-1], grid[1:]):
128
- x = model.flow_map(x, s.expand(len(x)), t.expand(len(x))) # X_{s,t}(x).
129
- return x # [N, 2] generated points.
130
-
131
-
132
- # %% 07. The report
133
- def main() -> None:
134
- parser = common_parser(__doc__.split("\n")[0])
135
- parser.set_defaults(steps=2000, batch_size=128, samples=512)
136
- args = parser.parse_args()
137
- set_seed(args.seed)
138
- device = pick_device(args.device)
139
- data = gaussian_mixture_2d(num_points=2048, seed=args.seed).to(device) # [2048, 2].
140
-
141
- report = scalar_check()
142
- report.update(gaussian_check())
143
- if not args.quiet:
144
- print(f"on dx/dt = x at s=0, t=1: v = {report['v_0,1(1)']:.6f} = b {report['b(1, 0)']:.6f}"
145
- f" + correction {report['d/ds v_0,1']:.6f}")
146
- print(f"one map evaluation {report['one map evaluation']:.6f} against one Euler step "
147
- f"{report['one Euler step']:.6f}, exact e = {math.e:.6f}")
148
-
149
- model = TwoTimeMLP(dim=2, width=args.width).to(device)
150
- if not args.quiet:
151
- print(f"\ntraining MeanFlow parameters={count_parameters(model):,}")
152
- losses = train(model, make_loss(model, data, args.batch_size, device),
153
- steps=args.steps, lr=args.lr, quiet=args.quiet)
154
-
155
- torch.manual_seed(args.seed + 1)
156
- x0 = torch.randn(args.samples, 2, device=device) # [N, 2] the source points every run shares.
157
- samples: list[str] = []
158
- for steps in (1, 2, 4):
159
- points = sample(model, x0, steps)
160
- stats = mode_report(points)
161
- report[f"{steps}-step nearest distance"] = stats["nearest_distance"]
162
- report[f"{steps}-step mode entropy"] = stats["mode_entropy"]
163
- report[f"{steps}-step log density"] = mean_log_density(points.cpu())
164
- samples.append(f"{steps} step(s): nearest {stats['nearest_distance']:.3f} "
165
- f"modes {stats['modes_found']} entropy {stats['mode_entropy']:.3f}")
166
- one, four = sample(model, x0, 1), sample(model, x0, 4)
167
- report["one step against four"] = float((one - four).norm(dim=1).mean())
168
- report["final loss"] = sum(losses[-20:]) / len(losses[-20:])
169
-
170
- print_report("MeanFlow, Chapter 6", report, samples)
171
- save_run(args.out, model=model, config=vars(args), report=report, losses=losses, samples=samples)
172
-
173
-
174
- if __name__ == "__main__":
175
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_6/posterior_map.py DELETED
@@ -1,221 +0,0 @@
1
- """Diamond Maps and Meta Flow Maps, the posterior over endpoints that guidance needs.
2
-
3
- Steering a flow toward a reward needs the value function, the log expected exponential reward over
4
- the terminal states reachable from the current one, and that average runs over a posterior. A
5
- deterministic map returns one endpoint, so the only estimate it supports is the reward at that
6
- endpoint, which is low by the Jensen gap. Giving the map a fresh noise argument and indexing it by
7
- the state it is conditioned on turns it into a family of inner flow maps, one per conditioning
8
- pair, whose draws are posterior samples.
9
-
10
- Chapter 6, Sections 6.33 and 6.34 of the notes. Runs on a two-point reward problem and on the four
11
- Gaussian blobs of ``cis6270.data.gaussian_mixture_2d``. Prints the Jensen gap of 0.434, the bias
12
- of the K-sample value at K = 1, 2, 4 and 8, the softmax-weighted value gradient of -0.4483, the
13
- importance weight that pulls a renoised proposal from 0.420 back to 0.0131, and the stop-gradient
14
- fixed point 0.625 of Meta Flow Maps.
15
-
16
- python lecture_6/posterior_map.py # 16 inner samples per state
17
- python lecture_6/posterior_map.py --steps 20 # fewer inner substeps, for a first look
18
- """
19
-
20
- # %% 01. Imports and configuration
21
- import math
22
-
23
- import torch
24
-
25
- from cis6270.data import gaussian_mixture_2d
26
- from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed
27
-
28
- torch.set_num_threads(1) # The work here is small, and one thread keeps the timing stable.
29
- # ``tests/conftest.py`` sets the same thing for the test session, so the line here is the one
30
- # that acts when a reader runs this script directly.
31
- torch.set_default_dtype(torch.float64) # Exact posteriors, compared against sample averages.
32
-
33
-
34
- INNER_STEPS = 48 # Substeps of the inner flow, which carries noise to a posterior sample.
35
-
36
-
37
- # %% 02. The tilt, the value function and the Jensen gap
38
- #
39
- # The tilted target is the data times the exponential reward, and the same reweighting acts on the
40
- # posterior over endpoints at every intermediate state. A deterministic map returns the posterior
41
- # mean, so its estimate of the value is the reward there, and the exponential is convex, so that
42
- # estimate is always the low one.
43
- def two_point(reward_gap: float = 2.0, x: float = 0.7, t: float = 0.8):
44
- """The chapter's two-point example, with z in {0, 2}, r(z) = z and a linear schedule."""
45
- weights = torch.tensor([1.0, math.exp(reward_gap)]) # e^{r(0)} and e^{r(2)}.
46
- tilted = weights / weights.sum() # The tilted target over the two points.
47
- far = math.exp(-((x - t * 2.0) ** 2) / (2.0 * (1.0 - t) ** 2)) # Unnormalized, at z = 2.
48
- near = math.exp(-(x ** 2) / (2.0 * (1.0 - t) ** 2)) # Unnormalized, at z = 0.
49
- posterior = torch.tensor([near, far]) / (near + far) # The posterior over endpoints at x.
50
- value = math.log(float((posterior * weights).sum())) # V_t(x) at that state.
51
- tilted_posterior = posterior * weights / float((posterior * weights).sum())
52
- exact = math.log(0.5 + 0.5 * math.exp(reward_gap)) # V over the data itself.
53
- return {
54
- "tilted target": [round(v, 4) for v in tilted.tolist()],
55
- "posterior at x=0.7": [round(v, 4) for v in posterior.tolist()],
56
- "tilted posterior": [round(v, 4) for v in tilted_posterior.tolist()],
57
- "V_t(0.7)": value,
58
- "V over the data": exact,
59
- "single-endpoint estimate": 1.0 * reward_gap / 2.0, # r at the posterior mean z = 1.
60
- "Jensen gap": exact - reward_gap / 2.0,
61
- }
62
-
63
-
64
- def value_bias(reward_gap: float = 2.0):
65
- """E[V_K] over K independent posterior draws, which approaches the exact value like 1/K."""
66
- exact = math.log(0.5 + 0.5 * math.exp(reward_gap))
67
- out = {}
68
- for k in (1, 2, 4, 8):
69
- expectation = sum(
70
- math.comb(k, j) * 2.0 ** (-k)
71
- * math.log(((k - j) + j * math.exp(reward_gap)) / k) for j in range(k + 1))
72
- out[f"E[V_{k}]"] = expectation
73
- out[f"bias at K={k}"] = exact - expectation
74
- return out
75
-
76
-
77
- # %% 03. The value gradient from K samples
78
- #
79
- # Differentiating the logarithm of an average of exponentials produces a softmax over the rewards,
80
- # so a sample whose reward is far above the others dominates the direction and a set with equal
81
- # rewards gives their plain average.
82
- def value_gradient(rewards, gradients):
83
- """The softmax weights over the rewards, and the weighted average of the reward gradients."""
84
- weights = torch.softmax(rewards, dim=0) # [K].
85
- return weights, float((weights * gradients).sum()), float(gradients.mean())
86
-
87
-
88
- # %% 04. The inner clock
89
- #
90
- # For a fixed conditioning pair the map is an ordinary two-time flow map on an inner time axis
91
- # running from the noise draw at 0 to a posterior sample at 1. Its velocity is the flow matching
92
- # velocity of the posterior, which is available in closed form over the training points.
93
- def posterior_weights(x, t, data):
94
- """p_{1|t}(z_i | x), a softmax over the training points under the linear interpolant. [B, N]."""
95
- spread = max(1.0 - t, 1e-6)
96
- squared = ((x[:, None, :] - t * data[None]) ** 2).sum(-1) # [B, N].
97
- return torch.softmax(-squared / (2.0 * spread ** 2), dim=1) # [B, N].
98
-
99
-
100
- def inner_map(noise, weights, data, substeps: int):
101
- """Y_{0,1}(eps | x, t), the inner flow from a Gaussian draw to a posterior sample. [B, K, 2]."""
102
- y = noise # [B, K, 2] one inner state per sample.
103
- step = 1.0 / substeps
104
- for i in range(substeps):
105
- a = i * step # The inner time.
106
- squared = ((y[:, :, None, :] - a * data[None, None]) ** 2).sum(-1) # [B, K, N].
107
- inner = torch.softmax(torch.log(weights[:, None, :].clamp_min(1e-30))
108
- - squared / (2.0 * max(1.0 - a, 1e-6) ** 2), dim=-1) # [B, K, N].
109
- denoiser = inner @ data # [B, K, 2] the inner posterior mean.
110
- y = y + step * (denoiser - y) / max(1.0 - a, 1e-6) # Euler on the inner velocity.
111
- return y
112
-
113
-
114
- # %% 05. Posterior samples from a map we already have
115
- #
116
- # We can renoise the current state back to an earlier time with fresh noise and push it through
117
- # the existing map, which discards information and widens the law of the endpoint. The correction
118
- # is an importance weight, written as a shift of the reward by the log likelihood of the current
119
- # state given that endpoint.
120
- def renoise_example(x: float = 0.7, t: float = 0.8, earlier: float = 0.6):
121
- """The chapter's renoised proposal, and the importance weight that recovers the posterior."""
122
- scale = earlier / t # alpha_{t'}/alpha_t on the linear schedule.
123
- variance = (1.0 - earlier) ** 2 - scale ** 2 * (1.0 - t) ** 2 # The variance added back.
124
- mean = scale * x # The center of the proposal.
125
- boundary = (earlier * 2.0 / 2.0 - mean) / math.sqrt(variance) # Where the map switches mode.
126
- proposal_far = 0.5 * math.erfc(boundary / math.sqrt(2.0)) # Share of draws reaching z = 2.
127
- penalty_far = (x - t * 2.0) ** 2 / (2.0 * (1.0 - t) ** 2) # Log likelihood of x given z = 2.
128
- penalty_near = x ** 2 / (2.0 * (1.0 - t) ** 2) # The same for z = 0.
129
- far = proposal_far * math.exp(-penalty_far)
130
- near = (1.0 - proposal_far) * math.exp(-penalty_near)
131
- return {"renoise scale": scale, "renoise variance": variance,
132
- "renoise standard deviation": math.sqrt(variance),
133
- "proposal weight on the far mode": proposal_far,
134
- "penalty at z=2": penalty_far, "penalty at z=0": penalty_near,
135
- "corrected weight on the far mode": far / (far + near)}
136
-
137
-
138
- # %% 06. Meta Flow Maps, the stop-gradient form
139
- #
140
- # The controlled velocity needs a ratio of two expectations, and regressing onto a per-sample
141
- # ratio is biased at every sample size. Multiplying through by the denominator leaves a condition
142
- # with one stop-gradient whose fixed point is the ratio of the expectations.
143
- def stop_gradient_fixed_point(weights, gradients, diffusion_squared: float = 2.0):
144
- """The fixed point of the loss, the per-sample ratio it is compared against, and the gap."""
145
- exact = 0.5 * diffusion_squared * float(gradients.mean()) / float(weights.mean())
146
- naive = float((gradients / weights).mean()) * 0.5 * diffusion_squared
147
- covariance = float(gradients.mean()) - float((gradients / weights).mean() * weights.mean())
148
- return {"E[w]": float(weights.mean()), "E[G]": float(gradients.mean()),
149
- "stop-gradient fixed point": exact, "per-sample ratio average": naive,
150
- "gap": naive - exact, "covariance of G/w and w": covariance,
151
- "gap from the covariance": -covariance / float(weights.mean())}
152
-
153
-
154
- # %% 07. The report
155
- def main() -> None:
156
- parser = common_parser(__doc__.split("\n")[0])
157
- parser.set_defaults(steps=INNER_STEPS, samples=16)
158
- args = parser.parse_args() # --steps is the inner substep count here, since nothing trains.
159
- set_seed(args.seed)
160
- pick_device(args.device) # Every number here is double precision, which not every
161
- # accelerator carries, so the work stays on the CPU whatever --device names.
162
- substeps = max(args.steps, 8)
163
-
164
- report = two_point()
165
- report.update(value_bias())
166
- weights, weighted, plain = value_gradient(torch.tensor([0.2, 1.5, 0.9]),
167
- torch.tensor([0.4, -1.2, 0.5]))
168
- report["softmax weights"] = [round(v, 4) for v in weights.tolist()]
169
- report["weighted value gradient"] = weighted
170
- report["plain average gradient"] = plain
171
- report.update(renoise_example())
172
- report.update(stop_gradient_fixed_point(torch.tensor([1.0, 2.0, 5.0]),
173
- torch.tensor([0.4, 1.0, 3.6])))
174
- if not args.quiet:
175
- print(f"Jensen gap {report['Jensen gap']:.3f} between the exact value "
176
- f"{report['V over the data']:.4f} and the single endpoint "
177
- f"{report['single-endpoint estimate']:.3f}")
178
- print(f"renoised proposal {report['proposal weight on the far mode']:.3f} corrected to "
179
- f"{report['corrected weight on the far mode']:.4f} against the posterior "
180
- f"{report['posterior at x=0.7'][1]:.4f}")
181
-
182
- # %% 08. The same quantities on the four Gaussian blobs
183
- data = gaussian_mixture_2d(num_points=256, seed=args.seed).double() # [N, 2] the endpoints.
184
- outer_t = 0.6 # The outer time the sampler is conditioned at.
185
- torch.manual_seed(args.seed + 1)
186
- index = torch.randint(0, len(data), (32,))
187
- states = ((1.0 - outer_t) * torch.randn(32, 2) + outer_t * data[index]) # [32, 2] contexts.
188
- states.requires_grad_(True)
189
- exact_weights = posterior_weights(states, outer_t, data) # [32, N] the exact posterior.
190
- reward = data[:, 0] # r(z) = the first coordinate, a reward with a clear direction.
191
- exact_value = torch.log((exact_weights * reward.exp()[None, :]).sum(1)) # [32] V_t(x).
192
- exact_grad = torch.autograd.grad(exact_value.sum(), states, retain_graph=True)[0] # [32, 2].
193
-
194
- noise = torch.randn(32, args.samples, 2) # [32, K, 2] inner draws, one per posterior sample.
195
- drawn = inner_map(noise, exact_weights, data, substeps) # [32, K, 2] posterior samples.
196
- sampled_reward = drawn[:, :, 0] # [32, K] the reward at each sample.
197
- estimate = torch.logsumexp(sampled_reward, dim=1) - math.log(args.samples) # [32] V hat.
198
- sample_grad = torch.autograd.grad(estimate.sum(), states, retain_graph=True)[0] # [32, 2].
199
- deterministic = (exact_weights @ reward) # [32] the reward at the posterior mean.
200
-
201
- exact_value, estimate = exact_value.detach(), estimate.detach() # Past the two gradients.
202
- deterministic, drawn = deterministic.detach(), drawn.detach()
203
- report["exact V, averaged"] = float(exact_value.mean())
204
- report[f"V from {args.samples} samples"] = float(estimate.mean())
205
- report["single endpoint, averaged"] = float(deterministic.mean())
206
- report["Jensen gap on the blobs"] = float((exact_value - deterministic).mean())
207
- report["posterior mean error"] = float(
208
- (drawn.mean(1) - exact_weights.detach() @ data).norm(dim=1).mean())
209
- report["value gradient error"] = float((sample_grad - exact_grad).norm(dim=1).mean())
210
- report["exact value gradient norm"] = float(exact_grad.norm(dim=1).mean())
211
- states = states.detach()
212
- samples = [f"state ({float(x[0]):+.2f}, {float(x[1]):+.2f}) exact V {float(v):.3f} "
213
- f"{args.samples} samples {float(e):.3f} one endpoint {float(d):.3f}"
214
- for x, v, e, d in zip(states[:4], exact_value[:4], estimate[:4], deterministic[:4])]
215
-
216
- print_report("Diamond Maps and Meta Flow Maps, Chapter 6", report, samples)
217
- save_run(args.out, config=vars(args), report=report, samples=samples)
218
-
219
-
220
- if __name__ == "__main__":
221
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_6/residuals.py DELETED
@@ -1,204 +0,0 @@
1
- """The three residuals of a flow map, and the diagonal anchor added to each one.
2
-
3
- The cocycle differentiated in the end time is the Lagrangian identity, differentiated in the start
4
- time it is the Eulerian identity, and left finite it is the composition identity. Each becomes a
5
- loss by squaring the amount the model violates it. None of the three mentions the data, so each is
6
- satisfied by a model that is self-consistent and wrong, and a flow matching loss on the diagonal
7
- supplies the one piece of information about the target.
8
-
9
- Chapter 6, Sections 6.15 to 6.17 and 6.23 of the notes. Trained on the four Gaussian blobs
10
- of ``cis6270.data.gaussian_mixture_2d``. Prints the stay-still residuals of Section 6.17, which are
11
- 1, 1 and 0, and then four trained models side by side in one-step sampling.
12
-
13
- python lecture_6/residuals.py # four models, 600 steps each
14
- python lecture_6/residuals.py --steps 20 # a few seconds, for a first look
15
- """
16
-
17
- # %% 01. Imports and configuration
18
- import torch
19
-
20
- from cis6270.data import GAUSSIAN_STD, gaussian_mixture_2d
21
- from cis6270.metrics import mode_report
22
- from cis6270.nets import TwoTimeMLP, count_parameters
23
- from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
24
-
25
-
26
- torch.set_num_threads(1) # These networks are small, and splitting one matrix product costs more
27
- # than it saves. ``tests/conftest.py`` sets the same thing for the test session, so the line
28
- # here is the one that acts when a reader runs this script directly.
29
-
30
- ON_MODE = 3.0 * GAUSSIAN_STD # A sample counts as on a mode within three data standard deviations.
31
- ODE_STEPS = 128 # Substeps used to integrate the model's own diagonal velocity accurately.
32
-
33
-
34
- # %% 02. The model, whose identity axiom is free
35
- #
36
- # The network produces the average velocity v_{s,t}(x), and the map is the state plus the elapsed
37
- # time times that velocity. At t = s the second term vanishes whatever the weights are, so no term
38
- # of the loss is spent on the identity axiom.
39
- class Map(torch.nn.Module):
40
- """X_{s,t}(x) = x + (t - s) v_{s,t}(x), with v the two-time network of ``cis6270.nets``."""
41
-
42
- def __init__(self, dim: int = 2, width: int = 128):
43
- super().__init__()
44
- self.net = TwoTimeMLP(dim=dim, width=width) # Reads x, s and t; returns a vector.
45
-
46
- def v(self, x, s, t): # the network
47
- return self.net(x, s, t) # [B, d] the average velocity over [s, t].
48
-
49
- def X(self, x, s, t): # identity axiom, free
50
- return self.net.flow_map(x, s, t) # [B, d] = x + (t - s) v_{s,t}(x).
51
-
52
-
53
- # %% 03. The diagonal anchor, which is the tangent axiom
54
- #
55
- # I_t = (1-t) x_0 + t x_1 and its derivative x_1 - x_0 are available in closed form, so the anchor
56
- # costs one network call and no simulation. Its minimizer is E[I_dot | I_t], the true velocity.
57
- def anchor(m, x0, x1, t): # tangent axiom
58
- xt = (1 - t[:, None]) * x0 + t[:, None] * x1 # [B, d] the interpolant at time t.
59
- return ((m.v(xt, t, t) - (x1 - x0)) ** 2).mean()
60
-
61
-
62
- # %% 04. The Lagrangian residual
63
- #
64
- # d/dt X = b at the output. The closure holds the state and the start time fixed, and a tangent of
65
- # one in t returns the map and its end-time derivative from a single forward-mode pass. The target
66
- # is the model's own diagonal velocity, detached, so the diagonal trains only through the anchor.
67
- def lagrangian(m, xs, s, t): # d/dt X = b at the output
68
- f = lambda tt: m.X(xs, s, tt)
69
- X, dXdt = torch.func.jvp(f, (t,), (torch.ones_like(t),))
70
- return ((dXdt - m.v(X, t, t).detach()) ** 2).mean()
71
-
72
-
73
- # %% 05. The Eulerian residual
74
- #
75
- # d/ds X + b . grad X = 0. Both the state and the start time are open in the closure, and the
76
- # tangent (v, 1) makes the one returned directional derivative the whole left-hand side. The
77
- # identity is already written as zero, so the residual needs no target at all.
78
- def eulerian(m, xs, s, t): # d/ds X + b . grad X = 0
79
- vs = m.v(xs, s, s).detach()
80
- f = lambda xx, ss: m.X(xx, ss, t)
81
- _, dX = torch.func.jvp(f, (xs, s), (vs, torch.ones_like(s)))
82
- return (dX ** 2).mean()
83
-
84
-
85
- # %% 06. The composition residual
86
- #
87
- # Two short maps against one long one, with no derivative of any kind. The detach covers the whole
88
- # composed target, so the two legs are frozen and only the long map is corrected.
89
- def composition(m, xs, s, u, t): # two short maps, one long one
90
- tgt = m.X(m.X(xs, s, u), u, t).detach()
91
- return ((m.X(xs, s, t) - tgt) ** 2).mean()
92
-
93
-
94
- # %% 07. The stay-still solution each residual has to rule out
95
- #
96
- # The average velocity equals the true b when the two times coincide and zero everywhere else, so
97
- # the map is the identity for every pair of times and the sampler generates the source. The
98
- # identity axiom, the cocycle and the anchor all hold on it, and the two differential residuals
99
- # separate it from the truth by a full unit.
100
- class StayStill(torch.nn.Module):
101
- """v_{s,t}(x) = b(x,t) on the diagonal and 0 off it, on the scalar flow dx/dt = x."""
102
-
103
- def v(self, x, s, t):
104
- on_diagonal = (s == t)[:, None] # [B, 1] true where the two times meet.
105
- return torch.where(on_diagonal, x, torch.zeros_like(x)) # b(x,t) = x on this flow.
106
-
107
- def X(self, x, s, t):
108
- return x # Off the diagonal (t-s) times zero, on it zero times b, so the map is the identity.
109
-
110
-
111
- def stay_still_residuals():
112
- """The three residuals of Section 6.17 at x = 1, s = 0, t = 1, which are 1, 1 and 0."""
113
- m = StayStill()
114
- x = torch.ones(1, 1) # [1, 1] the state x = 1.
115
- s, t, u = torch.zeros(1), torch.ones(1), torch.full((1,), 0.5)
116
- diagonal_gap = float(((m.v(x, t, t) - x) ** 2).mean()) # The anchor, at its minimum.
117
- return {
118
- "stay-still Lagrangian": float(lagrangian(m, x, s, t)),
119
- "stay-still Eulerian": float(eulerian(m, x, s, t)),
120
- "stay-still composition": float(composition(m, x, s, u, t)),
121
- "stay-still diagonal gap": diagonal_gap,
122
- }
123
-
124
-
125
- # %% 08. One training step, the same shape whichever residual we add
126
- def make_loss(model, data, residual, batch_size, device):
127
- """Draw a coupling, draw times from the triangle s <= t, and add the residual to the anchor."""
128
-
129
- def loss_fn(step: int) -> torch.Tensor:
130
- index = torch.randint(0, len(data), (batch_size,), device=device)
131
- x1 = data[index] # [B, 2] the data draw.
132
- x0 = torch.randn_like(x1) # [B, 2] the independent source draw.
133
- s = torch.rand(batch_size, device=device) # [B] start time.
134
- t = s + (1.0 - s) * torch.rand(batch_size, device=device) # [B] end time, s <= t <= 1.
135
- u = s + (t - s) * torch.rand(batch_size, device=device) # [B] intermediate time.
136
- total = anchor(model, x0, x1, torch.rand(batch_size, device=device)) # The data term.
137
- if residual is None:
138
- return total # The diagonal alone, which is flow matching read in one step.
139
- xs = (1 - s[:, None]) * x0 + s[:, None] * x1 # [B, 2] the interpolant at the start time.
140
- if residual is composition:
141
- return total + residual(model, xs, s, u, t)
142
- return total + residual(model, xs, s, t)
143
-
144
- return loss_fn
145
-
146
-
147
- # %% 09. One-step sampling, and the model's own ordinary differential equation
148
- @torch.no_grad()
149
- def evaluate(model, x0):
150
- """One-step output, the endpoint of the model's own diagonal velocity, and the mode statistics."""
151
- from cis6270.data import GAUSSIAN_CENTERS
152
-
153
- zeros = torch.zeros(len(x0), device=x0.device)
154
- one_step = model.X(x0, zeros, torch.ones_like(zeros)) # [N, 2] a single evaluation of the map.
155
- x, h = x0.clone(), 1.0 / ODE_STEPS # Integrate dx/dt = v_{t,t}(x) with the same weights.
156
- for k in range(ODE_STEPS):
157
- t = torch.full((len(x0),), k * h, device=x0.device)
158
- x = x + h * model.v(x, t, t) # Euler on the diagonal velocity the anchor trains.
159
- distance = float((one_step - x).norm(dim=1).mean()) # Column two of Table 6.2.
160
- nearest = torch.cdist(one_step, GAUSSIAN_CENTERS.to(one_step)).min(dim=1).values # [N].
161
- on_mode = float((nearest < ON_MODE).to(torch.get_default_dtype()).mean()) # Column three of Table 6.2.
162
- return one_step, distance, on_mode, mode_report(one_step)
163
-
164
-
165
- # %% 10. The report
166
- def main() -> None:
167
- parser = common_parser(__doc__.split("\n")[0])
168
- parser.set_defaults(samples=512)
169
- args = parser.parse_args()
170
- set_seed(args.seed)
171
- device = pick_device(args.device)
172
- data = gaussian_mixture_2d(num_points=2048, seed=args.seed).to(device) # [2048, 2] the target.
173
-
174
- report = stay_still_residuals() # Section 6.17, before any training.
175
- if not args.quiet:
176
- print(f"stay-still at x=1, s=0, t=1: Lagrangian {report['stay-still Lagrangian']:.3f} "
177
- f"Eulerian {report['stay-still Eulerian']:.3f} "
178
- f"composition {report['stay-still composition']:.3f}")
179
-
180
- recipes = [("the diagonal alone", None), ("+ Lagrangian", lagrangian),
181
- ("+ Eulerian", eulerian), ("+ composition", composition)]
182
- samples: list[str] = []
183
- for name, residual in recipes:
184
- set_seed(args.seed) # Same starting weights, same batches and same noise for all four.
185
- model = Map(dim=2, width=args.width).to(device)
186
- if not args.quiet:
187
- print(f"\ntraining {name} parameters={count_parameters(model):,}")
188
- losses = train(model, make_loss(model, data, residual, args.batch_size, device),
189
- steps=args.steps, lr=args.lr, quiet=args.quiet)
190
- torch.manual_seed(args.seed + 1) # The same source points for every model.
191
- x0 = torch.randn(args.samples, 2, device=device)
192
- _, distance, on_mode, stats = evaluate(model, x0)
193
- report[f"{name}: distance to its own ODE"] = distance
194
- report[f"{name}: fraction on a mode"] = on_mode
195
- report[f"{name}: final loss"] = sum(losses[-20:]) / len(losses[-20:])
196
- samples.append(f"{name:<20s} distance {distance:.3f} on a mode {on_mode:.2f} "
197
- f"modes found {stats['modes_found']}")
198
-
199
- print_report("Three residuals and the diagonal anchor, Chapter 6", report, samples)
200
- save_run(args.out, config=vars(args), report=report, samples=samples)
201
-
202
-
203
- if __name__ == "__main__":
204
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_6/shortcut.py DELETED
@@ -1,184 +0,0 @@
1
- """Shortcut models, the composition identity indexed by step size.
2
-
3
- The second input of the network is the length of the step and not the time at which it ends, and
4
- the identity is enforced only between a step and the step twice as long. One doubling constraint
5
- suffices because the ladder chains: with a finest step of 1/128 and seven doublings, the one-step
6
- map at level seven rests on seven relations and one regression against the data.
7
-
8
- Chapter 6, Section 6.20 of the notes. Trained on the four Gaussian blobs of
9
- ``cis6270.data.gaussian_mixture_2d``. Prints the doubling worked on dx/dt = x, where two steps of
10
- 0.25 average to 1.297443 and the direct step of 0.5 returns the same number, then the dyadic ladder
11
- and the trained model's doubling residual at every rung.
12
-
13
- python lecture_6/shortcut.py # 2000 steps, about twenty seconds
14
- python lecture_6/shortcut.py --steps 20 # a few seconds, for a first look
15
- """
16
-
17
- # %% 01. Imports and configuration
18
- import math
19
-
20
- import torch
21
-
22
- from cis6270.data import gaussian_mixture_2d
23
- from cis6270.metrics import mean_log_density, mode_report
24
- from cis6270.nets import TwoTimeMLP, count_parameters
25
- from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
26
-
27
-
28
- torch.set_num_threads(1) # These networks are small, and splitting one matrix product costs more
29
- # than it saves. ``tests/conftest.py`` sets the same thing for the test session, so the line
30
- # here is the one that acts when a reader runs this script directly.
31
-
32
- FINEST = 1.0 / 128.0 # d_0, the finest step of the ladder.
33
- LEVELS = 7 # log2(128) doublings, so the step sizes are d_k = 2^k d_0 for k = 0 to 7.
34
- ZERO_FRACTION = 0.75 # Three quarters of each batch is drawn at step size zero.
35
-
36
-
37
- # %% 02. The network in step-size coordinates
38
- #
39
- # The substitution s_theta(x, t, d) = v_{t, t+d}(x) turns the published network into the two-time
40
- # average velocity of this chapter, so the same weights serve both readings.
41
- def shortcut(model, x, t, d):
42
- """s_theta(x, t, d), the average velocity over the step of length d starting at time t."""
43
- return model(x, t, t + d) # [B, 2] = v_{t, t+d}(x).
44
-
45
-
46
- def take_step(model, x, t, d):
47
- """One step of length d, which is the flow map X_{t, t+d} in these coordinates."""
48
- return x + d[:, None] * shortcut(model, x, t, d) # [B, 2].
49
-
50
-
51
- # %% 03. The doubling identity, worked on the scalar flow
52
- #
53
- # s(x, t, 2d) = half s(x, t, d) + half s(x', t+d, d) with x' = x + d s(x, t, d). On dx/dt = x the
54
- # average velocity is x (e^d - 1)/d, and the identity holds at every step length at once.
55
- def scalar_doubling(x_value: float = 1.0, d: float = 0.25):
56
- """Every term of the doubling identity on dx/dt = x, each one computed here."""
57
- first = x_value * math.expm1(d) / d # s(1, 0, 0.25).
58
- reached = x_value + d * first # x', which is e^{0.25} to six figures.
59
- second = reached * math.expm1(d) / d # s(x', 0.25, 0.25), faster because the state has grown.
60
- doubled = x_value * math.expm1(2.0 * d) / (2.0 * d) # s(1, 0, 0.5), computed directly.
61
- return {
62
- "s(1, 0, d)": first,
63
- "state after the first step": reached,
64
- "s(x', d, d)": second,
65
- "average of the two": 0.5 * (first + second),
66
- "s(1, 0, 2d) direct": doubled,
67
- "doubling gap": abs(0.5 * (first + second) - doubled),
68
- "state after the doubled step": x_value + 2.0 * d * doubled,
69
- "exp(2d)": math.exp(2.0 * d),
70
- }
71
-
72
-
73
- # %% 04. The dyadic ladder
74
- #
75
- # Eight step sizes from 1/128 up to 1, each training pair tying level k to level k+1. A state at
76
- # level zero is already trained against the diagonal by the anchor, so the correctness of the
77
- # one-step map rests on a chain of seven relations and one regression.
78
- def ladder():
79
- """The step sizes d_k = 2^k d_0, from the finest to the whole interval."""
80
- return [FINEST * 2.0 ** k for k in range(LEVELS + 1)] # [1/128, ..., 1.0].
81
-
82
-
83
- # %% 05. The loss
84
- #
85
- # At step size zero the target is the flow matching target of Chapter 2, and above it the batch
86
- # draws a level of the ladder, forms the two short steps and regresses the doubled step onto their
87
- # average, with the target detached. The chapter puts three quarters of the batch on the zero step
88
- # and the rest on the doubling. Both terms are evaluated on the whole batch here and weighted in
89
- # that proportion, which has the same expectation at one network call more per step.
90
- def make_loss(model, data, batch_size, device):
91
- sizes = torch.tensor(ladder()[:LEVELS], device=device) # Levels whose doubling still fits in [0,1].
92
-
93
- def loss_fn(step: int) -> torch.Tensor:
94
- index = torch.randint(0, len(data), (batch_size,), device=device)
95
- x1 = data[index] # [B, 2] the data draw.
96
- x0 = torch.randn_like(x1) # [B, 2] the source draw.
97
- t = torch.rand(batch_size, device=device) # [B] a time for the zero-step rows.
98
- xt = (1 - t[:, None]) * x0 + t[:, None] * x1 # [B, 2] the interpolant.
99
- zero = torch.zeros(batch_size, device=device)
100
- anchor = ((shortcut(model, xt, t, zero) - (x1 - x0)) ** 2).mean() # Step size zero.
101
-
102
- d = sizes[torch.randint(0, LEVELS, (batch_size,), device=device)] # [B] one rung each.
103
- start = (1.0 - 2.0 * d) * torch.rand(batch_size, device=device) # [B] t with t + 2d <= 1.
104
- xs = (1 - start[:, None]) * x0 + start[:, None] * x1 # [B, 2] the interpolant at t.
105
- with torch.no_grad(): # The two legs are frozen; only the doubled step is corrected.
106
- first = shortcut(model, xs, start, d)
107
- reached = xs + d[:, None] * first
108
- second = shortcut(model, reached, start + d, d)
109
- target = 0.5 * (first + second)
110
- doubled = ((shortcut(model, xs, start, 2.0 * d) - target) ** 2).mean()
111
- return ZERO_FRACTION * anchor + (1.0 - ZERO_FRACTION) * doubled
112
-
113
- return loss_fn
114
-
115
-
116
- # %% 06. Sampling, and the residual at every rung
117
- @torch.no_grad()
118
- def sample(model, x0, steps: int):
119
- """Cross [0, 1] in equal steps of length 1/steps, one network evaluation per step."""
120
- x, d = x0, torch.full((len(x0),), 1.0 / steps, device=x0.device)
121
- for k in range(steps):
122
- t = torch.full((len(x0),), k / steps, device=x0.device)
123
- x = take_step(model, x, t, d)
124
- return x # [N, 2].
125
-
126
-
127
- @torch.no_grad()
128
- def rung_residuals(model, x0):
129
- """The squared doubling gap at each level of the ladder, measured on source points."""
130
- out = {}
131
- for k, d in enumerate(ladder()[:LEVELS]):
132
- step = torch.full((len(x0),), d, device=x0.device)
133
- start = torch.zeros(len(x0), device=x0.device)
134
- first = shortcut(model, x0, start, step)
135
- second = shortcut(model, x0 + step[:, None] * first, start + step, step)
136
- gap = shortcut(model, x0, start, 2.0 * step) - 0.5 * (first + second)
137
- out[f"rung {k}: d = 1/{int(round(1.0 / d))}"] = float((gap ** 2).mean())
138
- return out
139
-
140
-
141
- # %% 07. The report
142
- def main() -> None:
143
- parser = common_parser(__doc__.split("\n")[0])
144
- parser.set_defaults(steps=2000, batch_size=128, samples=512)
145
- args = parser.parse_args()
146
- set_seed(args.seed)
147
- device = pick_device(args.device)
148
- data = gaussian_mixture_2d(num_points=2048, seed=args.seed).to(device) # [2048, 2].
149
-
150
- report = scalar_doubling()
151
- report["ladder levels"] = LEVELS
152
- report["finest step d_0"] = FINEST
153
- if not args.quiet:
154
- print(f"on dx/dt = x: two steps of 0.25 average to {report['average of the two']:.6f}, "
155
- f"one step of 0.5 returns {report['s(1, 0, 2d) direct']:.6f}")
156
- print(f"ladder {[round(d, 5) for d in ladder()]}")
157
-
158
- model = TwoTimeMLP(dim=2, width=args.width).to(device)
159
- if not args.quiet:
160
- print(f"\ntraining the shortcut model parameters={count_parameters(model):,}")
161
- losses = train(model, make_loss(model, data, args.batch_size, device),
162
- steps=args.steps, lr=args.lr, quiet=args.quiet)
163
-
164
- torch.manual_seed(args.seed + 1)
165
- x0 = torch.randn(args.samples, 2, device=device) # [N, 2] the shared source points.
166
- report.update(rung_residuals(model, x0))
167
- samples: list[str] = []
168
- for steps in (1, 2, 4, 8):
169
- points = sample(model, x0, steps)
170
- stats = mode_report(points)
171
- report[f"{steps}-step nearest distance"] = stats["nearest_distance"]
172
- report[f"{steps}-step log density"] = mean_log_density(points.cpu())
173
- samples.append(f"{steps} step(s): nearest {stats['nearest_distance']:.3f} "
174
- f"modes {stats['modes_found']} entropy {stats['mode_entropy']:.3f}")
175
- report["one step against eight"] = float(
176
- (sample(model, x0, 1) - sample(model, x0, 8)).norm(dim=1).mean())
177
- report["final loss"] = sum(losses[-20:]) / len(losses[-20:])
178
-
179
- print_report("Shortcut models, Chapter 6", report, samples)
180
- save_run(args.out, model=model, config=vars(args), report=report, losses=losses, samples=samples)
181
-
182
-
183
- if __name__ == "__main__":
184
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_6/stochastic_map.py DELETED
@@ -1,218 +0,0 @@
1
- """Strong stochastic flow maps, where the driving path is an argument of the map.
2
-
3
- A map that matches only the transition kernel draws fresh noise on every leg, so one full step and
4
- two half steps follow different trajectories and their composition residual has a floor at twice
5
- the variance of the interval. A strong map takes the path itself, projected onto the first few
6
- shifted Legendre polynomials, and the Chen relations build the coefficients of a long interval
7
- from the coefficients of its halves, so all three legs of the cocycle are driven by one
8
- realization.
9
-
10
- Chapter 6, Section 6.31 of the notes. Runs on the linear equation dX = X dt + sigma dW
11
- and on a cloud of starting states from ``cis6270.data.gaussian_mixture_2d``. Prints the weak
12
- map's floor of 2K_1 = 6.389, the Legendre variances 1, 1/3 and 1/5, the Chen fusion that turns
13
- four half-interval draws into (0.50, -0.20), and the pathwise error against the coefficient count.
14
-
15
- python lecture_6/stochastic_map.py # 400,000 Monte Carlo draws, a few seconds
16
- python lecture_6/stochastic_map.py --steps 20 # the floor of 1,000 draws, for a first look
17
- """
18
-
19
- # %% 01. Imports and configuration
20
- import math
21
-
22
- import torch
23
-
24
- from cis6270.data import gaussian_mixture_2d
25
- from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed
26
-
27
- torch.set_num_threads(1) # The work here is elementwise, and one thread keeps the timing stable.
28
- # ``tests/conftest.py`` sets the same thing for the test session, so the line here is the one
29
- # that acts when a reader runs this script directly.
30
- torch.set_default_dtype(torch.float64) # The checks below sit well under the float32 noise floor.
31
-
32
-
33
- SIGMA = 1.0 # The noise level of dX = X dt + sigma dW.
34
- ORDER = 3 # Coefficients carried per interval, the number used in practice.
35
- FINEST = 32 # Subintervals the path is drawn on before the Chen relations fuse it upward.
36
-
37
-
38
- # %% 02. The transition kernel, and the weak map built from it
39
- #
40
- # Starting at x at time s, the state at t is e^{t-s} x plus a centered Gaussian whose variance is
41
- # K_{t-s}. A weak map reproduces that law on every interval with one fresh draw.
42
- def kernel_variance(d):
43
- """K_d = sigma^2 (e^{2d} - 1)/2, the variance the equation accumulates over a length d."""
44
- return 0.5 * SIGMA ** 2 * (math.exp(2.0 * d) - 1.0)
45
-
46
-
47
- def weak_step(x, d, noise):
48
- """The weak map, which takes a state and one fresh standard normal draw."""
49
- return math.exp(d) * x + math.sqrt(kernel_variance(d)) * noise
50
-
51
-
52
- # %% 03. The cost of matching only the transition kernel
53
- #
54
- # The variances compose correctly, so nothing is wrong at the level of distributions. At the level
55
- # of samples the three draws are independent, so the squared gap between one full step and two
56
- # half steps averages to twice the full-interval variance however well the map is trained.
57
- def weak_floor(x: float = 0.3, u: float = 0.5, t: float = 1.0, draws: int = 400_000, seed: int = 0):
58
- """The worked comparison of Section 6.31, and a Monte Carlo average of the floor."""
59
- full = weak_step(x, t, 0.8) # One step with the chapter's third draw.
60
- half = weak_step(weak_step(x, u, 0.5), t - u, -1.2) # Two legs with the first two draws.
61
- carried = math.exp(2.0 * (t - u)) * kernel_variance(u) + kernel_variance(t - u)
62
- matching = (math.exp(t - u) * math.sqrt(kernel_variance(u)) * 0.5
63
- - math.sqrt(kernel_variance(t - u)) * 1.2) / math.sqrt(kernel_variance(t))
64
- generator = torch.Generator().manual_seed(seed)
65
- e1, e2, e3 = (torch.randn(draws, generator=generator) for _ in range(3))
66
- gap = (math.sqrt(kernel_variance(t)) * e3
67
- - math.exp(t - u) * math.sqrt(kernel_variance(u)) * e1
68
- - math.sqrt(kernel_variance(t - u)) * e2) # [draws] the difference of the two routes.
69
- return {
70
- "K_1": kernel_variance(t), "sqrt K_1": math.sqrt(kernel_variance(t)),
71
- "sqrt K_0.5": math.sqrt(kernel_variance(u)),
72
- "one full step": full, "two half steps": half, "gap": full - half,
73
- "squared gap": (full - half) ** 2,
74
- "carried variance equals K_1": carried, "floor 2 K_1": 2.0 * kernel_variance(t),
75
- "the draw that would close it": matching,
76
- "Monte Carlo floor": float((gap ** 2).mean()),
77
- }
78
-
79
-
80
- # %% 04. The path as an input, through the shifted Legendre polynomials
81
- #
82
- # I^{(n)}_{s,t} integrates the n-th shifted Legendre polynomial of the normalized time against the
83
- # increment. The zeroth coefficient is the plain increment, the first separates a path that did
84
- # its travelling early from one that did it late, and distinct coefficients are independent.
85
- def legendre(n: int, r):
86
- """The n-th shifted Legendre polynomial on [0, 1]."""
87
- return [torch.ones_like(r), 2.0 * r - 1.0, 6.0 * r * r - 6.0 * r + 1.0][n]
88
-
89
-
90
- def draw_coefficients(shape, length: float, generator):
91
- """I^{(n)} ~ N(0, (t-s)/(2n+1)), drawn directly, with no path simulated."""
92
- scale = torch.tensor([math.sqrt(length / (2 * n + 1)) for n in range(ORDER)]) # [ORDER].
93
- return torch.randn(*shape, ORDER, generator=generator) * scale # [..., ORDER].
94
-
95
-
96
- def kernel_coefficients(length: float, nodes: int = 8001):
97
- """a_n(h) in e^{h(1-r)} = sum_n a_n P_n(r), which turns the Ito integral into a finite sum."""
98
- r = torch.linspace(0.0, 1.0, nodes)
99
- weight = torch.exp(length * (1.0 - r)) # The kernel e^{t-u} written in normalized time.
100
- return torch.stack([(2 * n + 1) * torch.trapz(weight * legendre(n, r), r)
101
- for n in range(ORDER)]) # [ORDER].
102
-
103
-
104
- def strong_step(x, length: float, coefficients):
105
- """The solution map over one interval, driven by the coefficients of the path on it."""
106
- kernel = kernel_coefficients(length).to(x) # [ORDER].
107
- return math.exp(length) * x + SIGMA * (coefficients * kernel).sum(-1) # Same shape as x.
108
-
109
-
110
- # %% 05. The Chen relations
111
- #
112
- # A shifted Legendre polynomial of the long interval expands in the shifted Legendre basis of each
113
- # half, so the coefficients fuse by a fixed linear rule with signs alternating in the parity of
114
- # n + m. The first rule says the increments add, and the minus sign in the second is the oddness
115
- # of P_1 about the midpoint.
116
- def chen(first, second):
117
- """Fuse the coefficients of two equal halves into the coefficients of their union."""
118
- return torch.stack([
119
- first[..., 0] + second[..., 0], # The increments add.
120
- -0.5 * (first[..., 0] - second[..., 0]) + 0.5 * (first[..., 1] + second[..., 1]),
121
- 0.25 * (first[..., 2] + second[..., 2]) - 0.75 * (first[..., 1] - second[..., 1]),
122
- ], dim=-1)
123
-
124
-
125
- def fuse_to(pieces, target: int):
126
- """Halve the number of intervals by Chen fusion until ``target`` intervals remain."""
127
- while pieces.shape[-2] > target:
128
- pieces = chen(pieces[..., 0::2, :], pieces[..., 1::2, :]) # Fuse neighbouring pairs.
129
- return pieces # [..., target, ORDER].
130
-
131
-
132
- def worked_chen():
133
- """The chapter's four half-interval draws, fused into the coefficients of the unit interval."""
134
- halves = torch.tensor([[0.80, 0.20, 0.0], [-0.30, 0.50, 0.0]]) # [2, ORDER], the two legs.
135
- fused = chen(halves[0], halves[1])
136
- return {"I0 over [0,1]": float(fused[0]), "I1 over [0,1]": float(fused[1]),
137
- "variance of I1 at h=1": 1.0 / 3.0,
138
- "fused variance at h=1": 0.25 * 0.5 + 0.25 * 0.5 + 0.25 / 6.0 + 0.25 / 6.0}
139
-
140
-
141
- # %% 06. The pathwise error against the number of coefficients
142
- #
143
- # A fine simulation gives both the exact Ito integral and the coefficients of the same path, so
144
- # the truncation can be measured on the realization it was taken from.
145
- def truncation_error(paths: int, grid: int, seed: int):
146
- """Mean absolute pathwise error of a one-step map reading 1, 2 and 3 coefficients."""
147
- generator = torch.Generator().manual_seed(seed)
148
- step = 1.0 / grid
149
- time = torch.arange(grid) * step # [grid] left endpoints of the substeps.
150
- increments = torch.randn(paths, grid, generator=generator) * math.sqrt(step) # [P, grid].
151
- exact = (torch.exp(1.0 - time)[None, :] * increments).sum(1) # [P] the Ito integral.
152
- measured = torch.stack([(legendre(n, time)[None, :] * increments).sum(1)
153
- for n in range(ORDER)], dim=1) # [P, ORDER].
154
- kernel = kernel_coefficients(1.0) # [ORDER].
155
- out = {f"coefficient variance I{n}": float(measured[:, n].var()) for n in range(ORDER)}
156
- for keep in range(1, ORDER + 1):
157
- approximate = (measured[:, :keep] * kernel[:keep]).sum(1) # [P].
158
- out[f"pathwise error with {keep}"] = float((exact - approximate).abs().mean())
159
- return out
160
-
161
-
162
- # %% 07. One path, several step counts
163
- #
164
- # A strong map crosses one trajectory of one realization in any number of hops, so its terminal
165
- # state barely moves with the step count. A weak map redraws at every change of the step count,
166
- # so its terminal state moves by the floor of Section 6.31.
167
- def cross(x, coefficients, steps: int, weak: bool, generator=None):
168
- """Cross [0, 1] in ``steps`` equal hops, either along the fused path or with fresh draws."""
169
- pieces = fuse_to(coefficients, steps) # [..., steps, ORDER] on this grid.
170
- length = 1.0 / steps
171
- for k in range(steps):
172
- if weak:
173
- noise = torch.randn(x.shape, generator=generator) # A fresh draw for every leg.
174
- x = weak_step(x, length, noise)
175
- else:
176
- x = strong_step(x, length, pieces[..., k, :])
177
- return x
178
-
179
-
180
- # %% 08. The report
181
- def main() -> None:
182
- parser = common_parser(__doc__.split("\n")[0])
183
- parser.set_defaults(steps=400_000, samples=512)
184
- args = parser.parse_args() # --steps is the Monte Carlo draw count here, since nothing trains.
185
- set_seed(args.seed)
186
- pick_device(args.device) # Every number here is double precision, which not every
187
- # accelerator carries, so the work stays on the CPU whatever --device names.
188
-
189
- report = weak_floor(draws=max(args.steps, 1000), seed=args.seed)
190
- report.update(worked_chen())
191
- report.update(truncation_error(paths=1024, grid=4096, seed=args.seed))
192
- if not args.quiet:
193
- print(f"weak floor 2K_1 = {report['floor 2 K_1']:.3f}, Monte Carlo "
194
- f"{report['Monte Carlo floor']:.3f}")
195
- print(f"pathwise error {report['pathwise error with 1']:.4f} with the increment alone "
196
- f"against {report['pathwise error with 3']:.6f} with three coefficients")
197
-
198
- # %% 09. The same cloud at four step counts
199
- points = gaussian_mixture_2d(num_points=args.samples, seed=args.seed).double() # [N, 2].
200
- generator = torch.Generator().manual_seed(args.seed + 1)
201
- path = draw_coefficients((args.samples, 2, FINEST), 1.0 / FINEST, generator) # [N, 2, F, 3].
202
- reference = cross(points, path, FINEST, weak=False) # The finest crossing, as the yardstick.
203
- samples: list[str] = []
204
- for steps in (2, 4, 8, 16): # Short of FINEST, which would be the yardstick against itself.
205
- strong = cross(points, path, steps, weak=False) # One realization, several hop counts.
206
- weak_generator = torch.Generator().manual_seed(args.seed + 2)
207
- weak = cross(points, path, steps, weak=True, generator=weak_generator)
208
- report[f"{steps} hops: strong spread"] = float((strong - reference).norm(dim=1).mean())
209
- report[f"{steps} hops: weak spread"] = float((weak - reference).norm(dim=1).mean())
210
- samples.append(f"{steps:2d} hops strong {float((strong - reference).norm(dim=1).mean()):.6f}"
211
- f" weak {float((weak - reference).norm(dim=1).mean()):.3f}")
212
-
213
- print_report("Strong stochastic flow maps, Chapter 6", report, samples)
214
- save_run(args.out, config=vars(args), report=report, samples=samples)
215
-
216
-
217
- if __name__ == "__main__":
218
- main()