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

Delete lecture_7

Browse files
lecture_7/README.md DELETED
@@ -1,166 +0,0 @@
1
- # Lecture 7: Optimal Transport and Schrodinger Bridges
2
-
3
- Chapter 7 of the notes.
4
-
5
- Every model of Chapters 2 through 6 trains on a pair of samples, and in almost all of them the two
6
- were drawn independently. This lecture takes the pairing as the quantity to be chosen. The first
7
- four files price a pairing, relax Monge's map to Kantorovich's coupling, certify the optimum with
8
- prices, smooth the problem with an entropy term that Sinkhorn's algorithm solves by rescaling rows
9
- and columns, and read transport as motion in time, where the minimizer moves every particle in a
10
- straight line. The next five replace the cost matrix by the endpoint law of a reference process,
11
- which turns the same scaling into a Schrodinger bridge: the chain rule puts reference bridges
12
- between every pair of ends, Doob's h-transform makes the bridge a process we can run forward, and
13
- three learning methods differ only in what they simulate and what they regress. Three more carry
14
- the construction to a finite state space, where a rate matrix replaces the drift, where a molecular
15
- graph factorizes into forty-five categorical features, and where discrete time makes the whole path
16
- law a table of sixteen numbers. The last file covers a population that splits into weighted branches
17
- and a system of interacting particles whose own dynamics is the reference.
18
-
19
- Almost every number below is computed in closed form, which is what the chapter does. Two files
20
- train.
21
-
22
- | file | command | what it computes |
23
- | ---- | ------- | ---------------- |
24
- | `optimal_transport.py` | `python lecture_7/optimal_transport.py` | the four points, the two-by-two plan and its prices, the sorted pairing on three points, and the Gaussian map |
25
- | `sinkhorn.py` | `python lecture_7/sinkhorn.py` | the Gibbs kernel, the first sweep by hand, the converged entropic plan, and the plan at four values of epsilon |
26
- | `displacement.py` | `python lecture_7/displacement.py` | the kinetic action of one particle and of a population, the displacement interpolation, and its velocity field |
27
- | `ot_flow_matching.py` | `python lecture_7/ot_flow_matching.py` | **trains** two velocity networks on the four-Gaussian target, one per coupling, and measures the straightness of each |
28
- | `static_bridge.py` | `python lecture_7/static_bridge.py` | the eight path probabilities, the chain rule, proportional fitting, the tilted matrices, and the bridge path law |
29
- | `dynamic_bridge.py` | `python lecture_7/dynamic_bridge.py` | Girsanov on a constant control, the backward and Hamilton-Jacobi-Bellman residuals, and the Brownian bridge |
30
- | `dsb.py` | `python lecture_7/dsb.py` | the two half-steps of proportional fitting on path laws, and the diffusion reversal on a Gaussian |
31
- | `dsbm.py` | `python lecture_7/dsbm.py` | the averaged bridge drift at one point, and one round of Markovian fitting on the chain |
32
- | `sf2m.py` | `python lecture_7/sf2m.py` | the two closed-form regression targets at a bridge sample, and the entropic plan of a batch |
33
- | `discrete_bridge.py` | `python lecture_7/discrete_bridge.py` | the two-state continuous-time bridge, its Euler sampler and its reverse rates, and the cost computed twice |
34
- | `ddsbm.py` | `python lecture_7/ddsbm.py` | the single bond with three types, the rates pinned at a terminal graph, and the counting over forty-five features |
35
- | `csbm.py` | `python lecture_7/csbm.py` | the two projections in discrete time, and D-IMF on sixteen paths against the reference's mixing |
36
- | `branched.py` | `python lecture_7/branched.py` | the two-branch energies, the tilt of a reference, and **trains** a bias force on two interacting particles |
37
-
38
- Every file takes `--seed` and `--quiet`, and `--steps` means the quantity that file iterates: the
39
- number of quantile levels, Sinkhorn sweeps, times on a grid, optimizer steps, fitting rounds or
40
- Euler steps. The slowest default run is about seven seconds; all thirteen run on a laptop CPU with
41
- no download.
42
-
43
- ## What a default run produces
44
-
45
- `optimal_transport.py`. The ordered pairing of the four points costs 1 against 5 for the crossed
46
- one and 3 for the independent coupling. The two-by-two optimum is `[[0.3, 0.3], [0, 0.4]]` at cost
47
- 3.4, the prices `f = (0, -8)` and `g = (1, 9)` have slack `[[0, 0], [8, 0]]` and dual value 3.4, so
48
- the duality gap is zero and `W_2 = 1.8439`. The six permutations of the three-point example cost
49
- 11/3, 17/3, 35/3, 15, 77/3 and 27, and the quantile integral on 3072 levels returns 3.6667.
50
- Transporting N(0,1) to N(3,4) gives `W_2^2 = 10` under the map `T(x) = 3 + 2x`, and the same map on
51
- a stratified sample gives 9.9996.
52
-
53
- `sinkhorn.py`. The kernel is `[[0.606531, 0.011109], [0.606531, 0.606531]]`. The first sweep gives
54
- `u = (0.971440, 0.329744)` and `v = (0.38013, 3.32081)`, and the marginal error falls 0.680, 0.382,
55
- 0.155, 0.047, 0.012, reaching 1e-9 after 17 sweeps. The entropic plan is
56
- `[[0.293122, 0.306878], [0.006878, 0.393122]]` at cost 3.45502 against 3.4, and the regularized
57
- objective is 1.208 at it, 1.222 at the exact optimum and 1.792 at independence. At epsilon = 10,
58
- 2, 0.5 and 0.1 the cost is 4.0540165, 3.4550204, 3.4000004 and 3.4000000, in 7, 17, 28 and 80
59
- sweeps.
60
-
61
- `displacement.py`. A steady particle has action 4 and a dawdling one 8, for the same endpoints. The
62
- average squared displacement is 1, 3 and 5 under the ordered, independent and crossed pairings. At
63
- t = 1/2 the displacement interpolation is N(1.5, 2.25) and the mixture of the two ends peaks at
64
- 0.13, with total variation 0.257 between them, and `W_2(rho_0, rho_1/2) = 1.5811`, half of 3.1623.
65
- The straight paths have action 9.9987, which is their average squared displacement. The action
66
- density is 9.9994 at every time on the grid, the stratified estimate of the exact 10, and the
67
- continuity residual is 1.1e-16.
68
-
69
- `ot_flow_matching.py`. On the two-point batch the independent coupling has average squared velocity
70
- 3 and the optimal one 1. Over training the batch coupling cost is 10.31 against 3.25, the learned
71
- paths have straightness 1.80 against 0.065, and the one-step error is 2.56 against 0.41. Both
72
- models find all four modes. At `--steps 1500`, about eighteen seconds, the optimal coupling reaches
73
- straightness 0.024 and one-step error 0.28 while the independent one does not improve.
74
-
75
- `static_bridge.py`. The eight reference paths sum to one with `R(1,1,2) = 0.105`. The chain rule
76
- splits 0.2180 into 0.0201 and 0.1979. The reference lands 0.73 away from the requested terminal
77
- law, and proportional fitting reaches `f = (1.072002, 0.937019)` and `g = (0.350517, 1.843637)`,
78
- with the terminal error falling by 187.5 per round. The bridge coupling is
79
- `[[0.114605, 0.385395], [0.085395, 0.414605]]` at `KL = 0.2820` against 0.2847 for independence.
80
- The tilted matrices carry (0.5, 0.5) to (0.449213, 0.550787) to (0.2, 0.8), the path ratios take
81
- four values, and `KL(P* || R) = 0.281961`.
82
-
83
- `dynamic_bridge.py`. The relative entropy of a constant control u = 2 is 2 from the control energy,
84
- 2 from the two endpoint laws and 1.9965 from the simulated paths. The backward residual is 2.2e-16
85
- and the Hamilton-Jacobi-Bellman residual 8.9e-16, and `-grad V` equals the bridge drift to 4.4e-16.
86
- At t = 0.25 the bridge has mean 0.5 and variance 0.1875, and at the state 0.6732 its drift is
87
- 1.7691 against the straight-line velocity 2. At t = 1/2 the simulated mean is 1.0095 and the
88
- simulated variance 0.2563, against the exact 1 and 0.25.
89
-
90
- `dsb.py`. The reversal of the last step is `[[0.681416, 0.318584], [0.379310, 0.620690]]`. The
91
- backward chain started from (0.2, 0.8) reaches (0.466585, 0.533415), which is 0.0668 from the source
92
- law, and the second half-step returns the terminal law (0.201967, 0.798033), off by 0.0039. That is
93
- the round-1 error of proportional fitting on the coupling, and the error falls by 187.5 per round.
94
- Run backward from its own terminal law, the diffusion reversal lands at variance 0.9836 against the
95
- exact 1.
96
-
97
- `dsbm.py`. Two bridges through x = 1 at t = 0.5 have drifts 4 and -2 and average 1, where the
98
- derivative of the squared error vanishes. One round of Markovian fitting gives
99
- `M_0 = [[0.591425, 0.408575], [0.307692, 0.692308]]` and
100
- `M_1 = [[0.298457, 0.701543], [0.119588, 0.880412]]`, whose endpoint coupling has exact marginals
101
- and brings the gap from 0.0584 to 0.0077. The gap then falls by 7.37 per round and nine rounds reach
102
- 1e-9.
103
-
104
- `sf2m.py`. At t = 0.25 with z = 0.4 the sample is 0.6732, the drift 1.7691, the score -0.9238, the
105
- flow target 2.2309 and the scaled score exactly -0.4. At t = 0.75 the coefficient changes sign and
106
- the flow target is 1.7691, so the two lie symmetrically about the straight-line velocity 2.
107
-
108
- `discrete_bridge.py`. Halving every rate costs 0.1534 and doubling costs 0.3863. The two-state
109
- bridge has `g = (0.3349, 1.9643)`, `phi_1/2 = (0.7999, 1.2667)` and the generator
110
- `[[-1.5836, 1.5836], [0.9472, -0.9472]]` at t = 1/2, where the marginal is (0.4668, 0.5332) and the
111
- reverse rates 1.3862 and 1.0821 match the forward flux at 0.7392. The Euler sampler's terminal
112
- error is 0.0223, 0.0106, 0.0051 and 0.0020 as the step is halved twice and then taken to a hundred
113
- steps. The cost is 0.3235 on the endpoints and 0.3235 on the paths, with the integrand 0.1300 at
114
- t = 1/2, and it rises 0.3148, 0.3235, 0.3337, 0.3348 toward the limit 0.3348 as the reference
115
- speeds up.
116
-
117
- `ddsbm.py`. The bond reference has `beta = 0.9163` and off-diagonal rate 0.3054, and the first
118
- column scaling is (0.2083, 0.7143, 2.9167). Two hundred sweeps give `f = (1.1779, 0.9506, 0.5062)`
119
- and `g = (0.1842, 0.6918, 3.0004)`, a plan that moves 0.4948 of the mass from no bond to double and
120
- leaves 0.2612 unchanged, between 0.18 for independence and 0.4 for the edit-minimizing plan. At
121
- r = 0.2, 0.4, 0.6, 0.8 and 0.99 the mass that stays is 0.2250, 0.2612, 0.2926, 0.3248 and 0.3812.
122
- The survival ratio reaches 1/2 at t = 0.2435, where the pinned rates are 1.2217, 0.0764 and 0.3054,
123
- a factor of 16 between a move onto the terminal type and a move off it, and one such edge against a
124
- network rate of 1 contributes 0.0229. With two terminal types the network learns 0.8552 at a loss
125
- of 0.1357, against 0.1467 at 1 and 0.2395 at 0.5. Nine heavy atoms give 45 features and 9.2e+27
126
- graphs, at a total jump rate of 24.74 per unit time.
127
-
128
- `csbm.py`. With alpha = 0.35 and three transitions, `A^3 = [[0.5135, 0.4865], [0.4865, 0.5135]]`
129
- and the exact bridge coupling is `[[0.10432, 0.39568], [0.09568, 0.40432]]`. The path 1,1,1,1 has
130
- reference bridge weight 0.5348, so its probability is 0.0535 at the start and 0.0558 under the
131
- bridge. The two laws agree at the interior times, (0.4754, 0.5246) and (0.4107, 0.5893), and differ
132
- by 0.0173 over the paths. One round of D-IMF brings that to 2.9e-04 and later rounds divide it by
133
- 58.9, reaching 2.4e-11 after five. The last conditional (0.3160, 0.6840) against a network at
134
- (0.25, 0.75) costs 0.0110 nats. At alpha = 0.2, 0.35 and 0.4 the starting gap is 0.13589, 0.01728
135
- and 0.00512 and the rounds divide it by 5, 59 and 284; on 8, 16 and 64 paths it is 0.05743, 0.01728
136
- and 0.00156 and the rounds divide it by 7, 59 and 4674. The ordered reference out of the middle of
137
- five categories is (0.0545, 0.2442, 0.4026, 0.2442, 0.0545).
138
-
139
- `branched.py`. The two branches have weighted energies 1.7 and 0.3 for a total of 2.0, and with the
140
- second target at -4 the linear schedule costs 2.9 against 2.6 for one that releases mass late. The
141
- bent interpolant has energy 2.1667 against 2 for the straight line. The two-outcome tilt has
142
- `Z = 0.109`, raises the chance of reaching B from 0.1 to 0.917, and costs 1.836 nats, with
143
- `J(b*) = -log Z = 2.216`. Training the bias force raises the mean terminal reward from -21.9 under
144
- the reference to -6.9. A longer run does not improve that, because the importance weights
145
- concentrate as the control grows and the effective sample size falls with it.
146
-
147
- ## What is simplified
148
-
149
- - Every example runs on the chapter's own two states, three bond types, four points or two
150
- particles. Nothing here is a benchmark, and no file touches a real dataset.
151
- - The minibatch coupling of `ot_flow_matching.py` is an exact assignment through
152
- `scipy.optimize.linear_sum_assignment`, which needs the two sides to be the same size. The
153
- chapter's general `n` by `m` problem with unequal masses appears only in `sinkhorn.py`.
154
- - The two training files use small networks and short runs, so their reports show the direction of
155
- an effect and not its converged size.
156
- - `dsb.py` and `dsbm.py` carry out their projections exactly on the full table of paths. The
157
- networks the chapter describes, which replace those projections by a regression when the state
158
- space is large, are not trained here.
159
- - `discrete_bridge.py` and `ddsbm.py` work one categorical feature at a time. `ddsbm.py` counts the
160
- forty-five features of a molecular graph and the rate of the product process, and no graph is
161
- built.
162
- - `csbm.py` runs both projections exactly on the sixteen-entry table. The network that replaces the
163
- conditionals, and the simulation that replaces the exact endpoint coupling, are not trained here.
164
- - `branched.py` uses constant growth rates taken from the worked example, and trains only the bias
165
- force of the entangled construction. The four-stage training of the branched construction and the
166
- state cost `V_t` are not fitted.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_7/branched.py DELETED
@@ -1,344 +0,0 @@
1
- """Two bridges that change the shape of the problem: branching, and interacting particles.
2
-
3
- A population leaving one source does not always end in one place, and Branched Schrodinger Bridge
4
- Matching gives each outcome its own controlled diffusion and its own share of the mass, with a
5
- learned growth rate moving mass between branches. Entangled Schrodinger Bridge Matching keeps one
6
- population and changes the reference, taking the molecular dynamics of the system itself and
7
- tilting it at the final time by a target density, which a bias force on every particle reproduces.
8
-
9
- Chapter 7, Sections 7.26 to 7.27.3 of the notes. Runs the two-branch example in closed form and
10
- trains a bias force on two interacting particles in the plane. Prints the weighted energies of the
11
- two branch schedules, the interpolant and its energy, the reweighting arithmetic of the two-outcome
12
- example, one Euler-Maruyama step, and the reward the trained force reaches.
13
-
14
- python lecture_7/branched.py # 100 optimizer steps on the bias force
15
- python lecture_7/branched.py --steps 20 # a few seconds, for a first look
16
- """
17
-
18
- # %% 01. Imports and configuration
19
- from __future__ import annotations
20
-
21
- import math
22
-
23
- import torch
24
- import torch.nn as nn
25
-
26
- from cis6270.nets import TimeEmbedding, count_parameters
27
- from cis6270.runner import (
28
- common_parser, pick_device, print_report, save_run, set_seed, train,
29
- )
30
-
31
- PARTICLES = 2 # n, the number of interacting particles.
32
- DIMENSION = 2 # Each particle lives in the plane, so the projector below has something to remove.
33
- MASS, FRICTION, TEMPERATURE = 1.0, 1.0, 1.0 # m_i, gamma and k_B tau, all in reduced units.
34
- NOISE = math.sqrt(2.0 * FRICTION * TEMPERATURE / MASS) # The noise scale of the reference.
35
- TARGET_WIDTH = 0.4 # The width of the target density around the region B.
36
- PATHS = 64 # Trajectories simulated per optimizer step.
37
- SIM_STEPS = 30 # Euler-Maruyama steps per trajectory.
38
-
39
-
40
- # %% 02. A population that splits
41
- #
42
- # Branch k is a controlled diffusion carrying the fraction w^k_t of the population, and the weights
43
- # change at a learned growth rate with sum_k g^k_t = 0, so a branch grows only by taking mass from
44
- # another. Section 7.26.
45
-
46
-
47
- def branch_weights(growth, t: torch.Tensor) -> torch.Tensor:
48
- """w^1_t = integral of the growth rate from 0 to t, with branch 0 holding the remainder."""
49
- return growth(t) # The weight of branch 1; branch 0 carries 1 - w^1_t.
50
-
51
-
52
- def weighted_energy(drift: float, weight, points: int = 2001) -> float:
53
- """The time integral of (1/2) ||u||^2 times the weight of the branch, by the trapezoid rule."""
54
- t = torch.linspace(0.0, 1.0, points)
55
- density = 0.5 * drift ** 2 * weight(t) # The control energy priced by the mass that spends it.
56
- return float(torch.trapezoid(density, t))
57
-
58
-
59
- def branch_sample(weight_one: float, generator: torch.Generator) -> int:
60
- """Draw a branch with probability w^k_t, which is how a sample of the population is taken."""
61
- return int(torch.rand(1, generator=generator) < weight_one) # 1 for branch 1, 0 for branch 0.
62
-
63
-
64
- # %% 03. The interpolant
65
- #
66
- # A network correction multiplied by t(1 - t) leaves both endpoints fixed for every value of the
67
- # parameters, so the endpoint constraints hold during training with no penalty term. With no state
68
- # cost the energy of a bent path exceeds the energy of the straight line, and the training drives
69
- # the correction to zero. Section 7.26.2.
70
-
71
-
72
- def interpolant(x0: float, x1: float, correction: float, t: torch.Tensor) -> torch.Tensor:
73
- """x_t = (1 - t) x_0 + t x_1 + t(1 - t) phi, with phi here a constant."""
74
- return (1.0 - t) * x0 + t * x1 + t * (1.0 - t) * correction
75
-
76
-
77
- def interpolant_velocity(x0: float, x1: float, correction: float, t: torch.Tensor) -> torch.Tensor:
78
- """x_dot = (x_1 - x_0) + (1 - 2t) phi, the derivative of the interpolant in t."""
79
- return (x1 - x0) + (1.0 - 2.0 * t) * correction
80
-
81
-
82
- def interpolant_energy(x0: float, x1: float, correction: float, points: int = 2001) -> float:
83
- """The integral of one half the squared velocity of the interpolant over the unit interval."""
84
- t = torch.linspace(0.0, 1.0, points)
85
- return float(torch.trapezoid(0.5 * interpolant_velocity(x0, x1, correction, t) ** 2, t))
86
-
87
-
88
- # %% 04. The reference of Entangled Schrodinger Bridge Matching
89
- #
90
- # The reference is the dynamics the system actually follows, underdamped Langevin with the physical
91
- # force, the friction and the thermal noise. Section 7.27.
92
-
93
-
94
- def potential(positions: torch.Tensor) -> torch.Tensor:
95
- """U(R), a double well on each particle plus a spring that couples the two. [B, n, d] -> [B]."""
96
- radial = ((positions ** 2).sum(dim=-1) - 1.0) ** 2 # [B, n] a well at every unit circle.
97
- coupling = 0.5 * ((positions[:, 0] - positions[:, 1]) ** 2).sum(dim=-1) # [B] the spring.
98
- return radial.sum(dim=-1) + coupling # [B].
99
-
100
-
101
- def physical_force(positions: torch.Tensor) -> torch.Tensor:
102
- """-grad_R U(R), differentiated by hand so the simulation needs no autograd. [B, n, d]."""
103
- radial = -4.0 * ((positions ** 2).sum(dim=-1, keepdim=True) - 1.0) * positions # [B, n, d].
104
- separation = positions[:, 0] - positions[:, 1] # [B, d] the displacement across the spring.
105
- spring = torch.stack([-separation, separation], dim=1) # [B, n, d] equal and opposite.
106
- return radial + spring
107
-
108
-
109
- def force_check(positions: torch.Tensor) -> float:
110
- """The largest gap between the force written by hand and the gradient autograd computes."""
111
- r = positions.detach().requires_grad_(True)
112
- gradient = torch.autograd.grad(potential(r).sum(), r)[0]
113
- return float((physical_force(positions) + gradient).abs().max())
114
-
115
-
116
- TARGET = torch.tensor([1.0, 0.0]) # The centre of the region B, the same for both particles.
117
-
118
-
119
- def log_target(positions: torch.Tensor) -> torch.Tensor:
120
- """r(X_1) = log pi_B(R_1), zero inside the region and negative outside. [B, n, d] -> [B]."""
121
- offset = positions - TARGET.to(positions) # [B, n, d] displacement from the target.
122
- return -(offset ** 2).sum(dim=(1, 2)) / (2.0 * TARGET_WIDTH ** 2) # [B].
123
-
124
-
125
- def target_direction(positions: torch.Tensor) -> torch.Tensor:
126
- """s_hat_i, the unit vector along the gradient of log pi_B for particle i. [B, n, d]."""
127
- gradient = -(positions - TARGET.to(positions)) / TARGET_WIDTH ** 2 # [B, n, d].
128
- return gradient / gradient.norm(dim=-1, keepdim=True).clamp(min=1e-8)
129
-
130
-
131
- # %% 05. The bias force and its parameterization
132
- #
133
- # A nonnegative scalar along the direction of steepest increase of the target density, plus a second
134
- # output with its component along that direction removed, so the push toward the target is never
135
- # negative and is read off directly as alpha. Section 7.27.3.
136
-
137
-
138
- def compose_bias(alpha: torch.Tensor, h: torch.Tensor, s_hat: torch.Tensor) -> torch.Tensor:
139
- """b = alpha s_hat + (I - s_hat s_hat^T) h, the parameterization of the force on each particle."""
140
- along = (h * s_hat).sum(dim=-1, keepdim=True) * s_hat # [B, n, d] the removed component.
141
- return alpha.unsqueeze(-1) * s_hat + (h - along) # [B, n, d].
142
-
143
-
144
- class BiasForce(nn.Module):
145
- """A network from the positions, the velocities and the time to one force per particle."""
146
-
147
- def __init__(self, width: int = 64):
148
- super().__init__()
149
- self.time_embedding = TimeEmbedding(width, scale=1.0)
150
- self.input = nn.Linear(2 * PARTICLES * DIMENSION, width) # Positions and velocities.
151
- self.body = nn.Sequential(nn.Linear(width, width), nn.SiLU(),
152
- nn.Linear(width, width), nn.SiLU())
153
- self.head = nn.Linear(width, PARTICLES * (1 + DIMENSION)) # alpha and h per particle.
154
-
155
- def forward(self, positions: torch.Tensor, velocities: torch.Tensor,
156
- t: torch.Tensor) -> torch.Tensor:
157
- """positions, velocities: [B, n, d], t: [B] -> the bias force [B, n, d]."""
158
- state = torch.cat([positions.flatten(1), velocities.flatten(1)], dim=1) # [B, 2nd].
159
- hidden = self.body(self.input(state) + self.time_embedding(t)) # [B, width].
160
- output = self.head(hidden).view(-1, PARTICLES, 1 + DIMENSION) # [B, n, 1 + d].
161
- alpha = nn.functional.softplus(output[..., 0]) # [B, n] nonnegative, so b never points away.
162
- return compose_bias(alpha, output[..., 1:], target_direction(positions))
163
-
164
-
165
- # %% 06. Simulating the controlled dynamics
166
- #
167
- # The control enters the velocity equation divided by the mass, against the noise scale, so in units
168
- # of the noise it is b / (m sigma) and Girsanov prices it at one half its squared size.
169
- # Sections 7.27.1 and 7.27.2.
170
-
171
-
172
- @torch.no_grad()
173
- def roll_out(force: BiasForce | None, paths: int, steps: int, device: torch.device,
174
- generator: torch.Generator) -> dict:
175
- """Run the Langevin dynamics with the given bias force, storing the states and the increments."""
176
- dt = 1.0 / steps
177
- positions = torch.full((paths, PARTICLES, DIMENSION), -1.0, device=device)
178
- positions[:, :, 1] = 0.0 # Both particles start at the far well.
179
- velocities = torch.randn(paths, PARTICLES, DIMENSION, generator=generator).to(device) * \
180
- math.sqrt(TEMPERATURE / MASS) # Maxwell-Boltzmann velocities at the reference temperature.
181
- stored_positions, stored_velocities, stored_u, increments = [], [], [], []
182
- energy = torch.zeros(paths, device=device) # (1/2) int ||u||^2 dt along each path.
183
- cross = torch.zeros(paths, device=device) # int u . dW along each path.
184
- for step in range(steps):
185
- t = torch.full((paths,), step * dt, device=device)
186
- u = (force(positions, velocities, t) / (MASS * NOISE)) if force is not None else \
187
- torch.zeros_like(positions) # The control in units of the noise.
188
- noise = torch.randn(paths, PARTICLES, DIMENSION,
189
- generator=generator).to(device) * math.sqrt(dt)
190
- stored_positions.append(positions.clone())
191
- stored_velocities.append(velocities.clone())
192
- stored_u.append(u.clone())
193
- increments.append(noise.clone())
194
- energy = energy + 0.5 * (u ** 2).sum(dim=(1, 2)) * dt
195
- cross = cross + (u * noise).sum(dim=(1, 2))
196
- acceleration = physical_force(positions) / MASS - FRICTION * velocities # The reference.
197
- velocities = velocities + (acceleration + NOISE * u) * dt + NOISE * noise
198
- positions = positions + velocities * dt # The position follows the velocity.
199
- return {
200
- "positions": torch.stack(stored_positions), "velocities": torch.stack(stored_velocities),
201
- "u_bar": torch.stack(stored_u), "noise": torch.stack(increments),
202
- "reward": log_target(positions), "energy": energy, "cross": cross, "dt": dt,
203
- }
204
-
205
-
206
- def importance_weights(rollout: dict) -> torch.Tensor:
207
- """softmax over the batch of r(X_1) + log dR / dP^bbar, which cancels the unknown Z."""
208
- log_weight = rollout["reward"] - rollout["cross"] - rollout["energy"]
209
- return torch.softmax(log_weight, dim=0) # [P] normalized weights, summing to one.
210
-
211
-
212
- def cross_entropy_loss(force: BiasForce, rollout: dict, weights: torch.Tensor) -> torch.Tensor:
213
- """The weighted average of -log dP^{b_theta} / dR along the stored paths, the loss of 7.27.3."""
214
- steps, paths = rollout["u_bar"].shape[0], rollout["u_bar"].shape[1]
215
- dt = rollout["dt"]
216
- clock = torch.arange(steps, device=weights.device).to(torch.get_default_dtype()) * dt
217
- times = clock[:, None].expand(steps, paths) # [T, P] the time of every stored state.
218
- u = force(rollout["positions"].flatten(0, 1), rollout["velocities"].flatten(0, 1),
219
- times.flatten()).view_as(rollout["u_bar"]) / (MASS * NOISE) # [T, P, n, d].
220
- drift = (u * (rollout["u_bar"] * dt + rollout["noise"])).sum(dim=(0, 2, 3)) # [P].
221
- energy = 0.5 * (u ** 2).sum(dim=(0, 2, 3)) * dt # [P] the control energy of each path.
222
- return (weights * (energy - drift)).sum() # Weighted by the importance weights.
223
-
224
-
225
- # %% 07. The report
226
- def main() -> None:
227
- parser = common_parser(__doc__.split("\n")[0])
228
- parser.set_defaults(steps=100, lr=3e-3, width=64, samples=256)
229
- args = parser.parse_args()
230
- set_seed(args.seed)
231
- device = pick_device(args.device)
232
- loud = not args.quiet
233
- generator = torch.Generator().manual_seed(args.seed)
234
-
235
- linear = lambda t: 0.3 * t # The growth rate 0.3, so w^1_t rises at a constant rate.
236
- late = lambda t: 0.3 * t ** 2 # The growth rate 0.6 t, which starts slowly.
237
- near = {"linear": weighted_energy(2.0, lambda t: 1.0 - linear(t))
238
- + weighted_energy(-2.0, linear)}
239
- far = {"linear": weighted_energy(2.0, lambda t: 1.0 - linear(t)) + weighted_energy(-4.0, linear),
240
- "late": weighted_energy(2.0, lambda t: 1.0 - late(t)) + weighted_energy(-4.0, late)}
241
- half = float(branch_weights(linear, torch.tensor(0.5)))
242
- if loud:
243
- print("Two branches from the point 0, ending at +2 with weight 0.7 and at -2 with 0.3")
244
- print(f" weights at t = 1/2: {1 - half:.2f} and {half:.2f}; at t = 1: "
245
- f"{1 - float(branch_weights(linear, torch.tensor(1.0))):.1f} and "
246
- f"{float(branch_weights(linear, torch.tensor(1.0))):.1f}")
247
- print(f" weighted energies {weighted_energy(2.0, lambda t: 1.0 - linear(t)):.1f} and "
248
- f"{weighted_energy(-2.0, linear):.1f}, total {near['linear']:.1f}")
249
- print(f" with the second target at -4: linear schedule {far['linear']:.1f}, "
250
- f"late schedule {far['late']:.1f}, saving {far['linear'] - far['late']:.1f}")
251
-
252
- bent = interpolant_energy(0.0, 2.0, 1.0)
253
- straight = interpolant_energy(0.0, 2.0, 0.0)
254
- grid = torch.tensor([0.0, 0.5, 1.0])
255
- if loud:
256
- print(f"\nThe interpolant with a correction of 1 passes through "
257
- f"{[round(float(z), 2) for z in interpolant(0.0, 2.0, 1.0, grid)]}")
258
- speed = [round(float(z), 0) for z in interpolant_velocity(0.0, 2.0, 1.0, grid)]
259
- print(f" its velocity is {speed} and its energy {bent:.4f}, against "
260
- f"{straight:.1f} for the straight line")
261
-
262
- reference_mass, inside, outside = 0.1, 1.0, 0.01 # The two-outcome example of Section 7.27.1.
263
- normalizer = reference_mass * inside + (1.0 - reference_mass) * outside
264
- tilted_mass = reference_mass * inside / normalizer
265
- reward_average = tilted_mass * math.log(inside) + (1.0 - tilted_mass) * math.log(outside)
266
- tilt_entropy = reward_average - math.log(normalizer)
267
- if loud:
268
- print(f"\nA reference reaching B one time in ten, tilted by pi_B = 1 inside and 0.01 outside")
269
- print(f" Z = {normalizer:.3f}, P*(B) = {tilted_mass:.3f}, "
270
- f"a factor of {tilted_mass / reference_mass:.2f}")
271
- print(f" E[r] = {reward_average:.3f}, -log Z = {-math.log(normalizer):.3f}, "
272
- f"KL(P* || R) = {tilt_entropy:.3f}, J(b*) = {tilt_entropy - reward_average:.3f}")
273
-
274
- step_drift = (-0.3 + 0.5) * 0.01 # The physical force plus the bias, over one step.
275
- step_friction = -1.0 * 0.8 * 0.01 # Minus the friction times the current velocity.
276
- step_noise = NOISE * math.sqrt(0.01) * 0.4 # The noise scale times a normal draw of 0.4.
277
- unit_control = 0.5 / NOISE # The bias force measured in units of the noise.
278
- plane = compose_bias(torch.tensor([[0.5]]), torch.tensor([[[0.3, -0.4]]]),
279
- torch.tensor([[[1.0, 0.0]]]))[0, 0] # The example of Section 7.27.3.
280
- log_weights = torch.tensor([-1.0, 0.0, 1.0])
281
- softmax = torch.softmax(log_weights, dim=0)
282
- if loud:
283
- print(f"\nOne Euler-Maruyama step at dt = 0.01, with a noise scale of {NOISE:.4f}")
284
- print(f" dv = {step_drift:.4f} {step_friction:+.4f} {step_noise:+.4f} = "
285
- f"{step_drift + step_friction + step_noise:.4f}, dr = {0.8 * 0.01:.4f}")
286
- print(f" the control in units of the noise is {unit_control:.4f}, contributing "
287
- f"{0.5 * unit_control ** 2 * 0.01:.6f} per step and "
288
- f"{0.5 * unit_control ** 2 * 0.01 * 100:.4f} over a hundred")
289
- print(f" b = {[round(float(z), 1) for z in plane]}, with alpha "
290
- f"{float((plane * torch.tensor([1.0, 0.0])).sum()):.1f} along s_hat "
291
- f"and length {float(plane.norm()):.3f}")
292
- print(f" three log-weights -1, 0, 1 normalize to {[round(float(z), 3) for z in softmax]}")
293
-
294
- force = BiasForce(args.width).to(device)
295
- reference_run = roll_out(None, args.samples, SIM_STEPS, device, generator)
296
- before = roll_out(force, args.samples, SIM_STEPS, device, generator)
297
-
298
- def loss_fn(step: int) -> torch.Tensor:
299
- rollout = roll_out(force, PATHS, SIM_STEPS, device, generator) # Simulate under b_bar.
300
- return cross_entropy_loss(force, rollout, importance_weights(rollout))
301
-
302
- if loud:
303
- print(f"\ntraining the bias force, {count_parameters(force):,} parameters, "
304
- f"{PATHS} paths of {SIM_STEPS} steps per optimizer step")
305
- losses = train(force, loss_fn, steps=args.steps, lr=args.lr, quiet=not loud, log_every=50)
306
- after = roll_out(force, args.samples, SIM_STEPS, device, generator)
307
- effective = float(1.0 / (importance_weights(after) ** 2).sum() / args.samples)
308
-
309
- report = {
310
- "weight of branch 1 at t = 1/2": half,
311
- "weighted energy, branch 0": weighted_energy(2.0, lambda t: 1.0 - linear(t)),
312
- "weighted energy, branch 1": weighted_energy(-2.0, linear),
313
- "total, both ends at distance 2": near["linear"],
314
- "total with the far target, linear": far["linear"],
315
- "total with the far target, late": far["late"],
316
- "a branch drawn at t = 1/2": branch_sample(half, generator),
317
- "interpolant energy, correction 1": bent,
318
- "interpolant energy, straight": straight,
319
- "Z of the two outcomes": normalizer,
320
- "P*(B)": tilted_mass,
321
- "the factor the tilt applies": tilted_mass / reference_mass,
322
- "E_{P*}[r]": reward_average,
323
- "KL(P* || R)": tilt_entropy,
324
- "-log Z, the value of J(b*)": -math.log(normalizer),
325
- "one velocity step": step_drift + step_friction + step_noise,
326
- "control in units of the noise": unit_control,
327
- "control energy over a hundred steps": 0.5 * unit_control ** 2 * 0.01 * 100,
328
- "the composed force": [round(float(z), 2) for z in plane],
329
- "its length": float(plane.norm()),
330
- "softmax of -1, 0, 1": [round(float(z), 3) for z in softmax],
331
- "mean reward, reference": float(reference_run["reward"].mean()),
332
- "mean reward, before training": float(before["reward"].mean()),
333
- "mean reward, after training": float(after["reward"].mean()),
334
- "control energy after training": float(after["energy"].mean()),
335
- "effective sample size, fraction": effective,
336
- "force written by hand against autograd": force_check(after["positions"][-1]),
337
- "final loss": sum(losses[-10:]) / len(losses[-10:]),
338
- }
339
- print_report("Branched and Entangled bridges, Chapter 7 Sections 7.26 to 7.27.3", report)
340
- save_run(args.out, config=vars(args), report=report) # Written only with --out.
341
-
342
-
343
- if __name__ == "__main__":
344
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_7/csbm.py DELETED
@@ -1,254 +0,0 @@
1
- """Categorical Schrodinger Bridge Matching: D-IMF on a path law small enough to write out.
2
-
3
- With N intermediate times a path is N + 2 states and the whole path law is a finite table, so both
4
- projections have one-line expressions. The reciprocal projection holds the endpoint coupling and
5
- redraws the interior from the reference, the Markov projection keeps the one-step conditionals and
6
- multiplies them into a chain, and the bridge is the only law in both sets. On two states and three
7
- transitions the sixteen paths fit in a list, so the alternation runs exactly.
8
-
9
- Chapter 7, Section 7.25 of the notes. Runs on S = 2, four time points, the uniform reference with
10
- alpha = 0.35, the source law (0.5, 0.5) and the requested law (0.2, 0.8). Prints the uniform and
11
- the ordered reference row, the exact bridge coupling, the conditionals of the first Markov
12
- projection, the gap after each round, and how that gap moves with alpha and with the path length.
13
-
14
- python lecture_7/csbm.py # 5 rounds of D-IMF, the five the chapter reports
15
- python lecture_7/csbm.py --steps 1 # the single round the chapter writes out
16
- """
17
-
18
- # %% 01. Imports and configuration
19
- from __future__ import annotations
20
-
21
- import torch
22
-
23
- from cis6270.runner import common_parser, print_report, save_run, set_seed
24
-
25
- DTYPE = torch.float64 # The gap reaches 1e-11 in five rounds, well below float32.
26
-
27
- ALPHA = 0.35 # The probability of changing category in one step.
28
- TRANSITIONS = 3 # N + 1, so four time points and sixteen paths.
29
- PI_0 = torch.tensor([0.5, 0.5], dtype=DTYPE) # The source law.
30
- PI_1 = torch.tensor([0.2, 0.8], dtype=DTYPE) # The requested law at the last time.
31
-
32
-
33
- # %% 02. The reference over S categories
34
- #
35
- # The uniform reference holds the category with probability 1 - alpha and spreads the rest evenly
36
- # over the alternatives. For categories that have an order a discretized Gaussian centred on the
37
- # current one is available instead, under which a neighbour is likelier than a distant category.
38
- # Section 7.25.
39
-
40
-
41
- def uniform_reference(alpha: float, size: int) -> torch.Tensor:
42
- """A_n(x, y) = 1 - alpha on the diagonal and alpha / (S - 1) off it. [S, S]."""
43
- off = alpha / (size - 1) # The share each alternative receives.
44
- return torch.full((size, size), off, dtype=DTYPE) + torch.eye(size, dtype=DTYPE) * (
45
- 1.0 - alpha - off)
46
-
47
-
48
- def ordered_reference(width: float, size: int) -> torch.Tensor:
49
- """Rows proportional to exp(-(y - x)^2 / 2 width^2), under which a nearby category is likelier."""
50
- offset = torch.arange(size, dtype=DTYPE)[None, :] - torch.arange(size, dtype=DTYPE)[:, None]
51
- weights = torch.exp(-offset ** 2 / (2.0 * width ** 2)) # [S, S] before normalizing.
52
- return weights / weights.sum(dim=1, keepdim=True)
53
-
54
-
55
- def sinkhorn(matrix: torch.Tensor, start: torch.Tensor, end: torch.Tensor, sweeps: int = 400
56
- ) -> torch.Tensor:
57
- """Scale diag(p_0) A^N to the two marginals, which gives the exact bridge coupling."""
58
- f = torch.ones_like(start)
59
- g = torch.ones_like(end)
60
- for _ in range(sweeps):
61
- g = end / (matrix.T @ f) # Column sums become exactly p_1.
62
- f = start / (matrix @ g) # Row sums become exactly p_0.
63
- return f[:, None] * matrix * g[None, :]
64
-
65
-
66
- # %% 03. The two projections in discrete time
67
- #
68
- # A path law is a tensor with one axis per time. The reciprocal projection multiplies the endpoint
69
- # coupling by the reference bridge weight of every path, and the Markov projection reads off the
70
- # one-step conditionals and multiplies them back into a chain. Section 7.25.1.
71
-
72
-
73
- def reference_joint(matrix: torch.Tensor, steps: int) -> torch.Tensor:
74
- """The product of the transition probabilities along a path, as a tensor with one axis per time."""
75
- joint = matrix.clone() # [x_0, x_1].
76
- for _ in range(steps - 1):
77
- joint = joint.unsqueeze(-1) * matrix # Append one more transition on a new axis.
78
- return joint # [S] * (steps + 1).
79
-
80
-
81
- def bridge_table(matrix: torch.Tensor, steps: int) -> torch.Tensor:
82
- """R(x_interior | x_0, x_N), the reference bridge weight of every path given its two ends."""
83
- whole = torch.linalg.matrix_power(matrix, steps) # [S, S] the reference over the interval.
84
- size = len(matrix)
85
- shape = [size] + [1] * (steps - 1) + [size] # Broadcast over the interior axes.
86
- return reference_joint(matrix, steps) / whole.reshape(shape)
87
-
88
-
89
- def reciprocal_projection(coupling: torch.Tensor, matrix: torch.Tensor, steps: int) -> torch.Tensor:
90
- """proj_R(P) = P(x_0, x_N) R(x_in | x_0, x_N), the endpoint coupling with reference bridges."""
91
- size = len(matrix)
92
- shape = [size] + [1] * (steps - 1) + [size]
93
- return coupling.reshape(shape) * bridge_table(matrix, steps)
94
-
95
-
96
- def markov_projection(law: torch.Tensor) -> tuple[list[torch.Tensor], torch.Tensor]:
97
- """proj_M(P) = P(x_0) prod_n P(x_{t_n} | x_{t_{n-1}}), as the conditionals and the coupling."""
98
- conditionals = []
99
- for step in range(law.dim() - 1):
100
- axes = [d for d in range(law.dim()) if d not in (step, step + 1)]
101
- pair = law.sum(dim=axes) # [S, S] the joint of two consecutive states.
102
- conditionals.append(pair / pair.sum(dim=1, keepdim=True))
103
- joint = torch.diag(law.sum(dim=list(range(1, law.dim())))) # [S, S] the law at time 0.
104
- for matrix in conditionals:
105
- joint = joint @ matrix
106
- return conditionals, joint
107
-
108
-
109
- def transition_loss(true: torch.Tensor, network: torch.Tensor) -> float:
110
- """KL(P(x_t | x_s) || m_theta(x_t | x_s)) on one transition out of one state, in nats."""
111
- return float((true * (true / network).log()).sum())
112
-
113
-
114
- # %% 04. D-IMF on sixteen paths
115
- #
116
- # Alternating the two projections is iterative Markovian fitting with discrete time. The starting
117
- # law is the independent coupling filled in with reference bridges, so both requested marginals are
118
- # already exact and only the joint law over paths moves. Section 7.25.2.
119
-
120
-
121
- def d_imf(coupling: torch.Tensor, matrix: torch.Tensor, steps: int, rounds: int,
122
- target: torch.Tensor) -> tuple[list[float], list[torch.Tensor]]:
123
- """Run the alternation, returning the gap after each round and the first round's conditionals."""
124
- gaps: list[float] = []
125
- first: list[torch.Tensor] = []
126
- for index in range(rounds):
127
- law = reciprocal_projection(coupling, matrix, steps) # Reference bridges in the interior.
128
- conditionals, coupling = markov_projection(law) # The chain and its endpoint coupling.
129
- if index == 0:
130
- first = conditionals
131
- gaps.append(float((reciprocal_projection(coupling, matrix, steps) - target).abs().sum()))
132
- return gaps, first
133
-
134
-
135
- def starting_gap(matrix: torch.Tensor, steps: int, start: torch.Tensor,
136
- end: torch.Tensor) -> tuple[float, torch.Tensor, torch.Tensor]:
137
- """The gap of the independent coupling from the bridge, with both path laws."""
138
- whole = torch.linalg.matrix_power(matrix, steps)
139
- plan = sinkhorn(torch.diag(start) @ whole, start, end)
140
- target = reciprocal_projection(plan, matrix, steps)
141
- initial = reciprocal_projection(start[:, None] * end[None, :], matrix, steps)
142
- return float((initial - target).abs().sum()), initial, target
143
-
144
-
145
- # %% 05. The report
146
- def main() -> None:
147
- parser = common_parser(__doc__.split("\n")[0])
148
- parser.set_defaults(steps=5) # --steps is the number of D-IMF rounds.
149
- args = parser.parse_args()
150
- set_seed(args.seed)
151
- loud = not args.quiet
152
- rounds = max(args.steps, 1)
153
-
154
- A = uniform_reference(ALPHA, len(PI_0))
155
- whole = torch.linalg.matrix_power(A, TRANSITIONS)
156
- ordered = ordered_reference(1.0, 5) # Five ordered categories, a width of one category.
157
- if loud:
158
- print(f"S = 2, {TRANSITIONS + 1} time points, alpha = {ALPHA}, "
159
- f"{len(PI_0) ** (TRANSITIONS + 1)} paths")
160
- print(f" A = {[[round(float(z), 2) for z in row] for row in A]}, "
161
- f"A^{TRANSITIONS} = {[[round(float(z), 4) for z in row] for row in whole]}")
162
- print(f" the ordered reference out of the middle of five categories: "
163
- f"{[round(float(z), 4) for z in ordered[2]]}")
164
-
165
- plan = sinkhorn(torch.diag(PI_0) @ whole, PI_0, PI_1)
166
- gap_0, initial, target = starting_gap(A, TRANSITIONS, PI_0, PI_1)
167
- interior = [initial.sum(dim=[d for d in range(TRANSITIONS + 1) if d != k]) for k in (1, 2)]
168
- independent = PI_0[:, None] * PI_1[None, :] # Where the alternation starts.
169
- if loud:
170
- print(f"\npi* = {[[round(float(z), 5) for z in row] for row in plan]}, against the "
171
- f"independent coupling "
172
- f"{[[round(float(z), 1) for z in row] for row in independent]}")
173
- print(f" the path 1,1,1,1 has reference bridge weight "
174
- f"{float(bridge_table(A, TRANSITIONS)[0, 0, 0, 0]):.4f}, so its probability is "
175
- f"{float(initial[0, 0, 0, 0]):.4f} at the start and {float(target[0, 0, 0, 0]):.4f} "
176
- f"under the bridge")
177
- print(f" the path 2,2,2,2 has {float(initial[1, 1, 1, 1]):.4f} and "
178
- f"{float(target[1, 1, 1, 1]):.4f}")
179
- print(f" the two laws agree at the interior times, "
180
- f"{[[round(float(z), 4) for z in m] for m in interior]}, and differ by "
181
- f"{gap_0:.4f} over the paths")
182
-
183
- gaps, conditionals = d_imf(independent, A, TRANSITIONS, rounds, target)
184
- # Eight rounds reach machine precision, past which a ratio of two gaps measures rounding, so
185
- # the report quotes the first one.
186
- ratios = [gaps[k] / gaps[k + 1] for k in range(len(gaps) - 1) if gaps[k + 1] > 1e-14]
187
- per_round = ratios[0] if ratios else gap_0 / gaps[0] # One round alone gives only the first.
188
- network = torch.tensor([0.25, 0.75], dtype=DTYPE) # A network that is wrong by a little.
189
- one_term = transition_loss(conditionals[-1][0], network)
190
- if loud:
191
- print(f"\nthe {len(conditionals)} conditionals of the first Markov projection")
192
- for matrix in conditionals:
193
- print(f" {[[round(float(z), 4) for z in row] for row in matrix]}")
194
- print(f" gap {gap_0:.4f} at the start, then {['%.1e' % x for x in gaps]}")
195
- print(f" ratios {[round(r, 1) for r in ratios]}")
196
- print(f" the true conditional {[round(float(z), 4) for z in conditionals[-1][0]]} against "
197
- f"a network at {[float(z) for z in network]} costs {one_term:.4f} nats")
198
-
199
- # A reference that mixes well and a longer chain both leave the bridge less to do, which the
200
- # starting gap records and the rate of convergence follows. Two rounds separate the two ratios
201
- # from the float64 floor, which the sixty-four-path column reaches after three.
202
- mixing = {}
203
- for alpha in (0.2, ALPHA, 0.4):
204
- matrix = uniform_reference(alpha, len(PI_0))
205
- start, _, goal = starting_gap(matrix, TRANSITIONS, PI_0, PI_1)
206
- later, _ = d_imf(independent, matrix, TRANSITIONS, 2, goal)
207
- mixing[alpha] = (start, later[0] / later[1])
208
- lengths = {}
209
- for steps in (2, TRANSITIONS, 5):
210
- start, _, goal = starting_gap(A, steps, PI_0, PI_1)
211
- later, _ = d_imf(independent, A, steps, 2, goal)
212
- lengths[steps] = (start, later[0] / later[1])
213
- if loud:
214
- print("\nalpha starting gap gap divided per round")
215
- for alpha, (start, ratio) in mixing.items():
216
- print(f" {alpha:<6g} {start:<14.5f} {ratio:.0f}")
217
- print("\npaths starting gap gap divided per round")
218
- for steps, (start, ratio) in lengths.items():
219
- print(f" {len(PI_0) ** (steps + 1):<6d} {start:<14.5f} {ratio:.0f}")
220
-
221
- report = {
222
- "A^3": [round(float(z), 4) for z in whole.flatten()],
223
- "the ordered reference row": [round(float(z), 4) for z in ordered[2]],
224
- "pi*_11": float(plan[0, 0]),
225
- "pi*_12": float(plan[0, 1]),
226
- "reference bridge weight of 1,1,1,1": float(bridge_table(A, TRANSITIONS)[0, 0, 0, 0]),
227
- "P^0(1,1,1,1)": float(initial[0, 0, 0, 0]),
228
- "P*(1,1,1,1)": float(target[0, 0, 0, 0]),
229
- "P^0(2,2,2,2)": float(initial[1, 1, 1, 1]),
230
- "P*(2,2,2,2)": float(target[1, 1, 1, 1]),
231
- "marginal at the first interior time": [round(float(z), 4) for z in interior[0]],
232
- "marginal at the second": [round(float(z), 4) for z in interior[1]],
233
- "gap at the start": gap_0,
234
- "gap after one round": gaps[0],
235
- "gap after the last round": gaps[-1],
236
- "rounds run": rounds,
237
- "gap divided per round": per_round,
238
- "first conditional, out of state 1": [round(float(z), 4) for z in conditionals[0][0]],
239
- "last conditional, out of state 1": [round(float(z), 4) for z in conditionals[-1][0]],
240
- "one transition against a wrong network": one_term,
241
- "starting gap at alpha = 0.2": mixing[0.2][0],
242
- "rounds divide the gap by, at alpha = 0.2": mixing[0.2][1],
243
- "rounds divide the gap by, at alpha = 0.4": mixing[0.4][1],
244
- "starting gap on eight paths": lengths[2][0],
245
- "starting gap on sixty-four paths": lengths[5][0],
246
- "rounds divide the gap by, on eight paths": lengths[2][1],
247
- "rounds divide the gap by, on sixty-four paths": lengths[5][1],
248
- }
249
- print_report("Categorical Schrodinger Bridge Matching, Chapter 7 Section 7.25", report)
250
- save_run(args.out, config=vars(args), report=report) # Written only with --out.
251
-
252
-
253
- if __name__ == "__main__":
254
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_7/ddsbm.py DELETED
@@ -1,243 +0,0 @@
1
- """Discrete Diffusion Schrodinger Bridge Matching: one categorical feature of a molecular graph.
2
-
3
- A molecule is a graph with a categorical feature on every atom and on every pair of atoms, and the
4
- reference resamples each feature independently, so one feature with S categories specifies the
5
- whole construction. Scaling the reference kernel to the two laws gives the bridge coupling for that
6
- feature, and pinning a path at a known terminal graph gives the rate a network regresses onto.
7
- Both stay usable on a state space of 10^27 graphs because the reference is a product over the
8
- features, so every sum over graphs becomes a sum over the categories of one of them.
9
-
10
- Chapter 7, Section 7.24 of the notes. Runs on one bond with three types, no bond, single and
11
- double, at the survival probability r = 0.4. Prints the kernel and its generator, the bridge
12
- coupling and the mass it leaves unchanged, the pinned rates and the posterior average a network
13
- learns, and the counting over the forty-five features of a nine-atom graph.
14
-
15
- python lecture_7/ddsbm.py # 200 Sinkhorn sweeps on the bond coupling
16
- python lecture_7/ddsbm.py --steps 1 # the first sweep alone, computed by hand
17
- """
18
-
19
- # %% 01. Imports and configuration
20
- from __future__ import annotations
21
-
22
- import math
23
-
24
- import torch
25
-
26
- from cis6270.runner import common_parser, print_report, save_run, set_seed
27
-
28
- DTYPE = torch.float64 # The chapter quotes four figures of the coupling and of the pinned rates.
29
-
30
- PRIOR = torch.full((3,), 1.0 / 3.0, dtype=DTYPE) # m, uniform over the three bond types.
31
- BOND_0 = torch.tensor([0.7, 0.2, 0.1], dtype=DTYPE) # pi_0, mostly no bond.
32
- BOND_1 = torch.tensor([0.1, 0.2, 0.7], dtype=DTYPE) # pi_1, mostly double.
33
- SURVIVAL = 0.4 # r = alpha_bar(1) / alpha_bar(0) over the whole unit interval.
34
- ATOMS = 9 # Heavy atoms in the molecule the chapter counts.
35
- ATOM_TYPES, BOND_TYPES = 5, 4 # Categories of a node feature and of a bond feature.
36
-
37
-
38
- # %% 02. The reference process on one feature
39
- #
40
- # The kernel keeps the category with probability r and redraws it from the prior m otherwise. Over
41
- # a short step r = 1 - h beta(t), so the rate out of x toward a different category y is the
42
- # resampling rate beta(t) times the chance m(y) that the redraw lands on y. Section 7.24.1.
43
-
44
-
45
- def replacement_kernel(r: float, prior: torch.Tensor) -> torch.Tensor:
46
- """K_st(x, y) = r 1_{x=y} + (1 - r) m(y), the uniform-replacement kernel with a general prior."""
47
- return r * torch.eye(len(prior), dtype=DTYPE) + (1.0 - r) * prior[None, :] # [S, S].
48
-
49
-
50
- def replacement_generator(beta: float, prior: torch.Tensor) -> torch.Tensor:
51
- """Q_t(x, y) = beta(t) m(y) off the diagonal, with the diagonal -beta(t) (1 - m(x))."""
52
- rates = beta * prior[None, :].repeat(len(prior), 1) # [S, S].
53
- rates = rates - torch.diag(rates.diagonal()) # Clear the diagonal before closing the row.
54
- return rates - torch.diag(rates.sum(dim=1))
55
-
56
-
57
- def resampling_rate(r: float) -> float:
58
- """beta = -log alpha_bar(1) for the schedule alpha_bar(t) = r^t, which is constant in time."""
59
- return -math.log(r)
60
-
61
-
62
- # %% 03. A bridge for a single bond
63
- #
64
- # Scaling diag(pi_0) K_01 to the two marginals gives the coupling. The mass it leaves on the
65
- # diagonal sits between the mass an independent coupling happens to leave and the largest any
66
- # coupling can leave, and r fixes where between the two it lands. Section 7.24.2.
67
-
68
-
69
- def sinkhorn(matrix: torch.Tensor, start: torch.Tensor, end: torch.Tensor, sweeps: int
70
- ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, list[float]]:
71
- """Scale a positive matrix to the two marginals, returning f, g, the plan and the column errors."""
72
- f = torch.ones_like(start)
73
- g = torch.ones_like(end)
74
- errors: list[float] = []
75
- for _ in range(sweeps):
76
- g = end / (matrix.T @ f) # Column sums become exactly pi_1.
77
- f = start / (matrix @ g) # Row sums become exactly pi_0, and the columns move again.
78
- errors.append(float(((f[:, None] * matrix * g[None, :]).sum(dim=0) - end).abs().sum()))
79
- return f, g, f[:, None] * matrix * g[None, :], errors
80
-
81
-
82
- def held_mass(plan: torch.Tensor) -> float:
83
- """The trace of the plan, the fraction of bonds whose type the bridge does not change."""
84
- return float(plan.diagonal().sum())
85
-
86
-
87
- # %% 04. Training with the terminal graph fixed
88
- #
89
- # Pinning the endpoint is an h-transform with the reference probability of arriving at x_1 in the
90
- # place of the potential, so a move onto the terminal type has its rate multiplied and a move off
91
- # it divided. Several terminal types can occur at one state, and the minimizer of the rate error
92
- # over a single number is their posterior average. Section 7.24.3.
93
-
94
-
95
- def pinned_rate(generator: torch.Tensor, kernel: torch.Tensor, x: int, y: int, x1: int) -> float:
96
- """Q_t(x, y; x_1) = Q_t(x, y) K_t1(y, x_1) / K_t1(x, x_1), the rate of the bridge ending at x_1."""
97
- return float(generator[x, y] * kernel[y, x1] / kernel[x, x1])
98
-
99
-
100
- def half_survival_time(r: float) -> float:
101
- """The time where alpha_bar(1) / alpha_bar(t) = r^{1-t} equals 1/2, on alpha_bar(t) = r^t."""
102
- return 1.0 - math.log(0.5) / math.log(r) # At r = 0.4 this is log 0.8 / log 0.4 = 0.2435.
103
-
104
-
105
- def rate_error(controlled: float, network: float) -> float:
106
- """Q^pin log(Q^pin / Q^theta) - Q^pin + Q^theta, the Bregman divergence of u log u on one edge."""
107
- return controlled * math.log(controlled / network) - controlled + network
108
-
109
-
110
- def posterior_loss(rates: list[float], weights: list[float], network: float) -> float:
111
- """The expected rate error at a state whose terminal type is uncertain, over the posterior."""
112
- return sum(w * rate_error(q, network) for q, w in zip(rates, weights))
113
-
114
-
115
- # %% 05. The factorization over a graph
116
- #
117
- # The reference applies the same kernel independently at every node and every pair of nodes, so the
118
- # generator changes one feature at a time, the total jump rate out of a graph is the sum of the
119
- # per-feature rates, and in the pinned rate every factor cancels but the one of the feature that
120
- # moves. Section 7.24.4.
121
-
122
-
123
- def feature_count(atoms: int) -> tuple[int, int, int]:
124
- """The node features, the bond features and their total, for a molecule with this many atoms."""
125
- bonds = atoms * (atoms - 1) // 2 # One feature per unordered pair.
126
- return atoms, bonds, atoms + bonds
127
-
128
-
129
- def state_space_size(atoms: int, atom_types: int, bond_types: int) -> float:
130
- """atom_types^nodes times bond_types^bonds, the number of graphs the sums would run over."""
131
- nodes, bonds, _ = feature_count(atoms)
132
- return float(atom_types ** nodes) * float(bond_types ** bonds)
133
-
134
-
135
- def total_jump_rate(features: int, beta: float, categories: int) -> float:
136
- """The sum of the per-feature rates out of a graph, which fixes the step size of the sampler."""
137
- return features * beta * (1.0 - 1.0 / categories) # A redraw lands elsewhere with 1 - m(x).
138
-
139
-
140
- # %% 06. The report
141
- def main() -> None:
142
- parser = common_parser(__doc__.split("\n")[0])
143
- parser.set_defaults(steps=200) # --steps is the number of Sinkhorn sweeps on the bond coupling.
144
- args = parser.parse_args()
145
- set_seed(args.seed)
146
- loud = not args.quiet
147
- sweeps = max(args.steps, 1)
148
-
149
- kernel = replacement_kernel(SURVIVAL, PRIOR)
150
- beta = resampling_rate(SURVIVAL)
151
- rates = replacement_generator(beta, PRIOR)
152
- reference = torch.diag(BOND_0) @ kernel
153
- if loud:
154
- print(f"One bond with three types at r = {SURVIVAL}, beta = {beta:.4f}, "
155
- f"off-diagonal rate {float(rates[0, 1]):.4f}")
156
- print(f" K_01 = {[[round(float(z), 1) for z in row] for row in kernel]}")
157
- print(f" diag(pi_0) K_01 has column sums "
158
- f"{[round(float(z), 2) for z in reference.sum(dim=0)]}, so the first g is "
159
- f"{[round(float(z), 4) for z in BOND_1 / reference.sum(dim=0)]}")
160
-
161
- f, g, plan, errors = sinkhorn(reference, BOND_0, BOND_1, sweeps)
162
- independent = float((BOND_0 * BOND_1).sum())
163
- largest = float(torch.minimum(BOND_0, BOND_1).sum())
164
- held = {r: held_mass(sinkhorn(torch.diag(BOND_0) @ replacement_kernel(r, PRIOR),
165
- BOND_0, BOND_1, 200)[2])
166
- for r in (0.2, 0.4, 0.6, 0.8, 0.99)}
167
- if loud:
168
- print(f"\nAfter {sweeps} sweeps, column error {errors[-1]:.2e}")
169
- print(f" f = {[round(float(z), 4) for z in f]}, "
170
- f"g = {[round(float(z), 4) for z in g]}")
171
- print(f" pi* = {[[round(float(z), 3) for z in row] for row in plan]}")
172
- print(f" mass that stays: bridge {held_mass(plan):.4f}, independent {independent:.2f}, "
173
- f"largest possible {largest:.1f}")
174
- print(f" diagonal mass at r = 0.2, 0.4, 0.6, 0.8, 0.99: "
175
- f"{[round(held[r], 4) for r in (0.2, 0.4, 0.6, 0.8, 0.99)]}")
176
-
177
- pinned_time = half_survival_time(SURVIVAL)
178
- pinned_kernel = replacement_kernel(0.5, PRIOR)
179
- toward = pinned_rate(rates, pinned_kernel, 0, 2, 2) # A move onto the terminal type.
180
- away = pinned_rate(rates, pinned_kernel, 2, 0, 2) # A move off it.
181
- between = pinned_rate(rates, pinned_kernel, 0, 1, 2) # Between two non-terminal types.
182
- one_edge = rate_error(toward, 1.0) # The term one edge contributes at a network rate of 1.
183
- weights = [0.6, 0.4] # The posterior over the two terminal types at this state.
184
- posterior = weights[0] * toward + weights[1] * between
185
- losses = {q: posterior_loss([toward, between], weights, q) for q in (posterior, 1.0, 0.5)}
186
- if loud:
187
- print(f"\nAt t = {pinned_time:.4f} the survival ratio to time 1 is 1/2, so K_t1 is "
188
- f"{float(pinned_kernel[0, 0]):.4f} on the diagonal and "
189
- f"{float(pinned_kernel[0, 1]):.4f} off it")
190
- print(f" pinned rates: {toward:.4f} toward the terminal type, {away:.4f} away from it "
191
- f"and {between:.4f} between two others, a factor of {toward / away:.0f}")
192
- print(f" one edge at a network rate of 1 contributes {one_edge:.4f}")
193
- print(f" with two terminal types the network learns {posterior:.4f}, at a loss of "
194
- f"{losses[posterior]:.4f}, against {losses[1.0]:.4f} at 1 and {losses[0.5]:.4f} at 0.5")
195
-
196
- nodes, bonds, features = feature_count(ATOMS)
197
- graphs = state_space_size(ATOMS, ATOM_TYPES, BOND_TYPES)
198
- jump_rate = total_jump_rate(bonds, beta, BOND_TYPES)
199
- if loud:
200
- print(f"\n{ATOMS} heavy atoms: {nodes} node features and {bonds} bond features, "
201
- f"{features} in all")
202
- print(f" {ATOM_TYPES}^{nodes} x {BOND_TYPES}^{bonds} = {graphs:.1e} graphs, "
203
- f"which no sum reaches")
204
- print(f" the total jump rate out of a graph is {bonds} x {beta:.4f} x "
205
- f"(1 - 1/{BOND_TYPES}) = {jump_rate:.2f} per unit time")
206
-
207
- report = {
208
- "beta of alpha_bar(t) = 0.4^t": beta,
209
- "off-diagonal reference rate": float(rates[0, 1]),
210
- "K_01 on the diagonal": float(kernel[0, 0]),
211
- "K_01 off the diagonal": float(kernel[0, 1]),
212
- "rows of the generator sum to": float(rates.sum(dim=1).abs().max()),
213
- "column sums of diag(pi_0) K_01": [round(float(z), 2) for z in reference.sum(dim=0)],
214
- "the first g": [round(float(z), 4) for z in BOND_1 / reference.sum(dim=0)],
215
- "sweeps run": sweeps,
216
- "column error after the last sweep": errors[-1],
217
- "f": [round(float(z), 4) for z in f],
218
- "g": [round(float(z), 4) for z in g],
219
- "bond plan, no bond to double": float(plan[0, 2]),
220
- "bond mass that stays": held_mass(plan),
221
- "bond mass, independent coupling": independent,
222
- "bond mass, largest possible": largest,
223
- "diagonal mass at r = 0.2": held[0.2],
224
- "diagonal mass at r = 0.99": held[0.99],
225
- "time where the survival ratio is 1/2": pinned_time,
226
- "pinned rate toward the terminal": toward,
227
- "pinned rate away from it": away,
228
- "pinned rate between two others": between,
229
- "one edge at a network rate of 1": one_edge,
230
- "posterior average of the rates": posterior,
231
- "loss at the posterior average": losses[posterior],
232
- "loss at 1": losses[1.0],
233
- "loss at 0.5": losses[0.5],
234
- "node, bond and total features": [nodes, bonds, features],
235
- "graphs on nine heavy atoms": f"{graphs:.1e}",
236
- "total jump rate out of a graph": jump_rate,
237
- }
238
- print_report("Discrete Diffusion Schrodinger Bridge Matching, Chapter 7 Section 7.24", report)
239
- save_run(args.out, config=vars(args), report=report) # Written only with --out.
240
-
241
-
242
- if __name__ == "__main__":
243
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_7/discrete_bridge.py DELETED
@@ -1,267 +0,0 @@
1
- """The Schrodinger bridge over a finite set: rates in place of drifts.
2
-
3
- On a finite state space there is no small displacement, so the control is a reweighting of jump
4
- rates and relative entropy on paths is a time integral of a Bregman divergence between two rate
5
- matrices. Everything else carries over. The chain rule splits the problem into an endpoint term and
6
- a bridge term, the two potentials are the Sinkhorn scalings of the reference endpoint law, and
7
- under Doob's h-transform each reference rate is multiplied by the ratio of a backward potential
8
- at the destination to its value at the current state.
9
-
10
- Chapter 7, Sections 7.21 to 7.23.3 of the notes. Runs the two-state continuous-time example with
11
- the generator Q, the source law (0.5, 0.5) and the requested terminal law (0.2, 0.8). Prints the
12
- bridge rates at three times, the reverse rates, the Euler sampler's error as the step shrinks,
13
- and the cost computed on the endpoints and on the paths.
14
-
15
- python lecture_7/discrete_bridge.py # 10 Euler steps, then 20, 40 and 100
16
- python lecture_7/discrete_bridge.py --steps 20 # a finer sampler, for a first look
17
- """
18
-
19
- # %% 01. Imports and configuration
20
- from __future__ import annotations
21
-
22
- import math
23
-
24
- import torch
25
-
26
- from cis6270.runner import common_parser, print_report, save_run, set_seed
27
-
28
- DTYPE = torch.float64 # Matrix exponentials and a time integral, both in double precision.
29
-
30
- Q = torch.tensor([[-1.0, 1.0], [1.5, -1.5]], dtype=DTYPE) # The reference generator.
31
- PI_0 = torch.tensor([0.5, 0.5], dtype=DTYPE) # The source law.
32
- PI_1 = torch.tensor([0.2, 0.8], dtype=DTYPE) # The requested terminal law.
33
- STATIONARY = torch.tensor([0.6, 0.4], dtype=DTYPE) # Proportional to (1.5, 1), the rows of Q.
34
- GRID = 1001 # Points in the Simpson rule for the time integral of the rate error.
35
-
36
-
37
- # %% 02. Relative entropy between two jump processes
38
- #
39
- # Collecting the one-step relative entropies and letting the step go to zero leaves a time integral
40
- # of the Bregman divergence of u log u between the two rates, which is nonnegative and vanishes
41
- # only when they agree. The divergence is not symmetric in the ratio. Section 7.21.1.
42
-
43
-
44
- def rate_error(controlled: torch.Tensor, reference: torch.Tensor) -> torch.Tensor:
45
- """Q^theta log(Q^theta / Q) - Q^theta + Q, the Bregman divergence of u log u, entry by entry."""
46
- safe = controlled.clamp(min=1e-300) # A zero rate contributes the reference rate alone.
47
- return controlled * (safe / reference).log() - controlled + reference
48
-
49
-
50
- def scaling_cost(c: float) -> float:
51
- """c log c - c + 1, the cost per unit of reference rate of scaling every rate by c."""
52
- return c * math.log(c) - c + 1.0
53
-
54
-
55
- # %% 03. The Schrodinger problem for a chain
56
- #
57
- # The endpoint law of a chain with a time-independent generator is diag(pi_0^ref) e^Q, and the two
58
- # potentials are the Sinkhorn scalings of that matrix to the two marginals. Section 7.21.2.
59
-
60
-
61
- def endpoint_law(generator: torch.Tensor, start: torch.Tensor) -> torch.Tensor:
62
- """R_01 = diag(pi_0) e^Q, the reference probability of starting at x and ending at y."""
63
- return torch.diag(start) @ torch.matrix_exp(generator) # [S, S].
64
-
65
-
66
- def sinkhorn(matrix: torch.Tensor, start: torch.Tensor, end: torch.Tensor, sweeps: int = 400
67
- ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
68
- """Scale a positive matrix to the two marginals, returning f, g and the plan f R_01 g."""
69
- f = torch.ones_like(start)
70
- g = torch.ones_like(end)
71
- for _ in range(sweeps):
72
- g = end / (matrix.T @ f) # Column sums become exactly pi_1.
73
- f = start / (matrix @ g) # Row sums become exactly pi_0.
74
- return f, g, f[:, None] * matrix * g[None, :]
75
-
76
-
77
- def relative_entropy(p: torch.Tensor, q: torch.Tensor) -> float:
78
- """Sum over entries of p log(p / q), for two positive matrices or vectors."""
79
- mask = p > 0
80
- return float((p[mask] * (p[mask] / q[mask]).log()).sum())
81
-
82
-
83
- # %% 04. Rates reweighted by an h-transform
84
- #
85
- # The backward potential is g averaged over the reference from the current time to time 1, and each
86
- # off-diagonal rate is multiplied by the ratio of that potential at the destination to its value at
87
- # the current state. The diagonal is then set so that the row sums to zero. Section 7.22.
88
-
89
-
90
- def backward_potential(generator: torch.Tensor, g: torch.Tensor, t: float) -> torch.Tensor:
91
- """phi_t = e^{Q(1-t)} g, the reference average of g at the final state seen from time t."""
92
- return torch.matrix_exp(generator * (1.0 - t)) @ g # [S].
93
-
94
-
95
- def tilted_generator(generator: torch.Tensor, phi: torch.Tensor) -> torch.Tensor:
96
- """Q*(x, y) = Q(x, y) phi(y) / phi(x) off the diagonal, with the diagonal closing each row."""
97
- tilted = generator * phi[None, :] / phi[:, None] # [S, S] every entry reweighted.
98
- tilted.fill_diagonal_(0.0) # Discard the reweighted diagonal, which has no meaning.
99
- return tilted - torch.diag(tilted.sum(dim=1)) # Each row now sums to zero.
100
-
101
-
102
- def bridge_marginal(generator: torch.Tensor, start: torch.Tensor, f: torch.Tensor,
103
- g: torch.Tensor, t: float) -> torch.Tensor:
104
- """p*_t = psi_t phi_t, the forward potential carried under the reference times the backward one."""
105
- psi = (start * f) @ torch.matrix_exp(generator * t) # [S] the forward factor.
106
- return psi * backward_potential(generator, g, t) # [S] their entrywise product.
107
-
108
-
109
- # %% 05. Sampling the bridge with Euler steps
110
- #
111
- # Over a step the chain moves to a different state with probability dt Q*(x, y) and stays with the
112
- # remaining probability, which is the Euler sampler of Chapter 4 applied to a generator that
113
- # changes with time. Section 7.23.1.
114
-
115
-
116
- def euler_marginal(generator: torch.Tensor, start: torch.Tensor, f: torch.Tensor,
117
- g: torch.Tensor, steps: int) -> tuple[torch.Tensor, list[float]]:
118
- """Propagate p <- p (I + dt Q*_t) and record the probability of leaving state 1 at each step."""
119
- dt = 1.0 / steps
120
- p = start.clone()
121
- leaving = []
122
- identity = torch.eye(len(start), dtype=DTYPE)
123
- for step in range(steps):
124
- tilted = tilted_generator(generator, backward_potential(generator, g, step * dt))
125
- leaving.append(float(tilted[0, 1] * dt)) # The jump probability out of state 1.
126
- p = p @ (identity + dt * tilted) # One Euler step of the master equation.
127
- return p, leaving
128
-
129
-
130
- # %% 06. The same bridge run backwards
131
- #
132
- # Matching the probability flux on an edge in both directions fixes the reverse rate, which is the
133
- # forward rate times the ratio of the marginal at the two ends of the edge. Section 7.23.2.
134
-
135
-
136
- def reverse_rates(tilted: torch.Tensor, marginal: torch.Tensor) -> torch.Tensor:
137
- """Q_hat(y, x) = Q(x, y) p_t(x) / p_t(y), the rates under which the flux on an edge matches."""
138
- return (tilted * marginal[:, None] / marginal[None, :]).T # [S, S], reversed in both indices.
139
-
140
-
141
- # %% 07. The cost of the bridge, computed twice
142
- #
143
- # On the endpoints the cost is the relative entropy of the plan from the reference endpoint law. On
144
- # the paths it is the time integral of the rate error, weighted by the marginal of the bridge at
145
- # that time. The two agree. Section 7.23.3.
146
-
147
-
148
- def path_cost(generator: torch.Tensor, start: torch.Tensor, f: torch.Tensor, g: torch.Tensor,
149
- points: int = GRID) -> tuple[float, float]:
150
- """Integrate the weighted rate error over [0, 1] by Simpson's rule.
151
-
152
- Returns the integral and the value of the integrand at t = 1/2, the one the chapter prints.
153
- """
154
- off = ~torch.eye(len(generator), dtype=torch.bool) # [S, S] true away from the diagonal.
155
- times = torch.linspace(0.0, 1.0, points, dtype=DTYPE)
156
- values = []
157
- for t in times.tolist():
158
- tilted = tilted_generator(generator, backward_potential(generator, g, t))
159
- marginal = bridge_marginal(generator, start, f, g, t)
160
- error = torch.zeros_like(tilted)
161
- error[off] = rate_error(tilted[off], generator[off]) # Only the edges carry a cost.
162
- values.append(float((marginal * error.sum(dim=1)).sum()))
163
- weights = torch.ones(points, dtype=DTYPE)
164
- weights[1:-1:2] = 4.0 # Simpson's rule on an even number of intervals.
165
- weights[2:-1:2] = 2.0
166
- integral = float((weights * torch.tensor(values, dtype=DTYPE)).sum() / (3.0 * (points - 1)))
167
- return integral, values[(points - 1) // 2]
168
-
169
-
170
- # %% 08. The report
171
- def main() -> None:
172
- parser = common_parser(__doc__.split("\n")[0])
173
- parser.set_defaults(steps=10) # --steps is the coarsest number of Euler steps in the table.
174
- args = parser.parse_args()
175
- set_seed(args.seed)
176
- loud = not args.quiet
177
- coarse = max(args.steps, 2)
178
-
179
- if loud:
180
- print("Scaling every reference rate by c costs c log c - c + 1 per unit of rate")
181
- print(f" halving {scaling_cost(0.5):.4f}, doubling {scaling_cost(2.0):.4f}")
182
-
183
- R01 = endpoint_law(Q, PI_0)
184
- f, g, plan = sinkhorn(R01, PI_0, PI_1)
185
- phi_half = backward_potential(Q, g, 0.5)
186
- tilted_half = tilted_generator(Q, phi_half)
187
- tilted_zero = tilted_generator(Q, backward_potential(Q, g, 0.0))
188
- tilted_one = tilted_generator(Q, g)
189
- marginal_half = bridge_marginal(Q, PI_0, f, g, 0.5)
190
- marginal_one = bridge_marginal(Q, PI_0, f, g, 1.0)
191
- reverse = reverse_rates(tilted_half, marginal_half)
192
- if loud:
193
- print(f"\ne^Q = {[[round(float(z), 4) for z in row] for row in torch.matrix_exp(Q)]}")
194
- print(f"R_01 = {[[round(float(z), 4) for z in row] for row in R01]}, "
195
- f"column sums {[round(float(z), 4) for z in R01.sum(0)]}")
196
- print(f" f = {[round(float(z), 4) for z in f]}, g = {[round(float(z), 4) for z in g]}, "
197
- f"g ratio {float(g[1] / g[0]):.3f}")
198
- print(f" pi* = {[[round(float(z), 4) for z in row] for row in plan]}")
199
- print(f" phi_1/2 = {[round(float(z), 4) for z in phi_half]}, "
200
- f"ratio {float(phi_half[1] / phi_half[0]):.2f}")
201
- print(f" Q*_1/2 = {[[round(float(z), 4) for z in row] for row in tilted_half]}")
202
- print(f" rates out of the two states: at t = 0 "
203
- f"{float(tilted_zero[0, 1]):.4f} and {float(tilted_zero[1, 0]):.4f}, "
204
- f"at t = 1 {float(tilted_one[0, 1]):.4f} and {float(tilted_one[1, 0]):.4f}")
205
- print(f" marginal at t = 1/2 {[round(float(z), 4) for z in marginal_half]}, "
206
- f"at t = 1 {[round(float(z), 4) for z in marginal_one]}")
207
- print(f" reverse rates at t = 1/2: {float(reverse[1, 0]):.4f} from 2 back to 1, "
208
- f"{float(reverse[0, 1]):.4f} from 1 back to 2")
209
- print(f" the flux on the edge: {float(marginal_half[0] * tilted_half[0, 1]):.4f} "
210
- f"against {float(marginal_half[1] * reverse[1, 0]):.4f}")
211
-
212
- sampler = {}
213
- for steps in (coarse, 2 * coarse, 4 * coarse, 10 * coarse):
214
- terminal, leaving = euler_marginal(Q, PI_0, f, g, steps)
215
- sampler[steps] = (terminal, float((terminal - PI_1).abs().sum() / 2), leaving)
216
- if loud:
217
- print("\nsteps terminal law error first and last jump probability")
218
- for steps, (terminal, error, leaving) in sampler.items():
219
- print(f" {steps:<5d} ({float(terminal[0]):.4f}, {float(terminal[1]):.4f})"
220
- f" {error:.4f} {leaving[0]:.4f}, {leaving[-1]:.4f}")
221
-
222
- endpoint_cost = relative_entropy(plan, R01)
223
- integral, at_half = path_cost(Q, PI_0, f, g)
224
- speeds = {}
225
- for lam in (0.5, 1.0, 2.0, 4.0):
226
- scaled_law = endpoint_law(lam * Q, PI_0)
227
- _, _, scaled_plan = sinkhorn(scaled_law, PI_0, PI_1)
228
- speeds[lam] = relative_entropy(scaled_plan, scaled_law)
229
- limit = relative_entropy(PI_1, STATIONARY)
230
- if loud:
231
- print(f"\nKL(pi* || R_01) = {endpoint_cost:.4f}")
232
- print(f" the integrand at t = 1/2 is {at_half:.4f}, and its integral is {integral:.4f}")
233
- print(f" at lambda = 1/2, 1, 2, 4 the cost is "
234
- f"{[round(speeds[k], 4) for k in (0.5, 1.0, 2.0, 4.0)]}")
235
- print(f" the limit KL(pi_1 || (0.6, 0.4)) is {limit:.4f}")
236
-
237
- report = {
238
- "cost of halving every rate": scaling_cost(0.5),
239
- "cost of doubling every rate": scaling_cost(2.0),
240
- "R_01 column sums": [round(float(z), 4) for z in R01.sum(dim=0)],
241
- "f": [round(float(z), 4) for z in f],
242
- "g": [round(float(z), 4) for z in g],
243
- "pi*_11": float(plan[0, 0]),
244
- "phi at t = 1/2": [round(float(z), 4) for z in phi_half],
245
- "bridge rate out of state 1": float(tilted_half[0, 1]),
246
- "bridge rate out of state 2": float(tilted_half[1, 0]),
247
- "rate out of state 1 at t = 1": float(tilted_one[0, 1]),
248
- "marginal at t = 1/2": [round(float(z), 4) for z in marginal_half],
249
- "marginal at t = 1": [round(float(z), 4) for z in marginal_one],
250
- "reverse rate, 2 back to 1": float(reverse[1, 0]),
251
- "Euler error at the coarsest step": sampler[coarse][1],
252
- "Euler error, step halved": sampler[2 * coarse][1],
253
- "Euler error, step quartered": sampler[4 * coarse][1],
254
- "Euler error at ten times the steps": sampler[10 * coarse][1],
255
- "KL(pi* || R_01)": endpoint_cost,
256
- "rate error integrand at t = 1/2": at_half,
257
- "its integral over [0, 1]": integral,
258
- "cost at lambda = 1/2": speeds[0.5],
259
- "cost at lambda = 4": speeds[4.0],
260
- "KL(pi_1 || the stationary law)": limit,
261
- }
262
- print_report("The bridge over a finite set, Chapter 7 Sections 7.21 to 7.23.3", report)
263
- save_run(args.out, config=vars(args), report=report) # Written only with --out.
264
-
265
-
266
- if __name__ == "__main__":
267
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_7/displacement.py DELETED
@@ -1,237 +0,0 @@
1
- """Transport as motion in time: the kinetic action, straight lines, and displacement interpolation.
2
-
3
- A plan records which source point ends where and nothing about the trajectory between them. The
4
- Benamou-Brenier formulation replaces the plan by a density and a velocity field and measures the
5
- transport by the energy the particles expend, and its minimizer moves every particle in a straight
6
- line at constant speed with the endpoints paired by the optimal plan.
7
-
8
- Chapter 7, Sections 7.7 to 7.7.5 of the notes. Runs on the one-particle example of Section 7.7.1
9
- and on the Gaussian pair N(0,1) to N(3,4), both in closed form. Prints the action of a steady and a
10
- dawdling particle, the two sides of the averaged bound, the interpolated density against the mixture
11
- of the endpoints, and the velocity field with its action density.
12
-
13
- python lecture_7/displacement.py # 1024 times on the grid
14
- python lecture_7/displacement.py --steps 20 # a coarse time grid, for a first look
15
- """
16
-
17
- # %% 01. Imports and configuration
18
- from __future__ import annotations
19
-
20
- import math
21
-
22
- import torch
23
-
24
- from cis6270.runner import common_parser, print_report, save_run, set_seed
25
-
26
- DTYPE = torch.float64 # Closed forms throughout, so the printed digits are the chapter's.
27
-
28
- MEAN_0, STD_0 = 0.0, 1.0 # The source N(0, 1).
29
- MEAN_1, STD_1 = 3.0, 2.0 # The target N(3, 4), whose standard deviation is 2.
30
-
31
-
32
- # %% 02. One particle
33
- #
34
- # The squared displacement of a particle is at most the integral of its squared speed, by
35
- # Cauchy-Schwarz against the constant function 1 on an interval of length 1, with equality when the
36
- # velocity is constant in time. Section 7.7.1.
37
-
38
-
39
- def action_of_a_path(positions: torch.Tensor, times: torch.Tensor) -> float:
40
- """The integral of the squared speed along a trajectory sampled at the given times."""
41
- velocity = (positions[1:] - positions[:-1]) / (times[1:] - times[:-1]) # [n-1] piecewise speed.
42
- return float((velocity ** 2 * (times[1:] - times[:-1])).sum()) # Integral of ||x_dot||^2.
43
-
44
-
45
- def steady_particle(times: torch.Tensor) -> torch.Tensor:
46
- """A particle covering the distance 2 at the constant speed 2, so its action is 4."""
47
- return 2.0 * times # x(t) = 2t.
48
-
49
-
50
- def dawdling_particle(times: torch.Tensor) -> torch.Tensor:
51
- """A particle that waits until t = 1/2 and then covers the same distance at speed 4."""
52
- return torch.clamp(4.0 * (times - 0.5), min=0.0) # Zero on [0, 1/2], then 4(t - 1/2).
53
-
54
-
55
- # %% 03. Averaging the bound over the particles
56
- #
57
- # The joint law of the two endpoints of a particle is a coupling of mu and nu, so the action is at
58
- # least the average squared displacement under that coupling, which is at least W_2^2.
59
- # Section 7.7.2.
60
-
61
-
62
- def average_squared_displacement(x: torch.Tensor, y: torch.Tensor, pi: torch.Tensor) -> float:
63
- """The average of ||x_1 - x_0||^2 over endpoint pairs drawn from the coupling pi."""
64
- return float((pi * (x[:, None] - y[None, :]) ** 2).sum()) # Sum over routes of mass times cost.
65
-
66
-
67
- # %% 04. Straight lines attain the bound
68
- #
69
- # Drawing the endpoint pair from the optimal coupling and moving along the segment between them
70
- # makes the velocity constant, so Cauchy-Schwarz is tight for every particle, and the coupling is
71
- # optimal, so the second inequality is tight as well. Section 7.7.3.
72
-
73
-
74
- def straight_path(x0: torch.Tensor, x1: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
75
- """x(t) = (1 - t) x_0 + t x_1, whose velocity is the constant x_1 - x_0."""
76
- return (1.0 - t) * x0 + t * x1 # Same shape as x0 and x1 broadcast against t.
77
-
78
-
79
- # %% 05. Displacement interpolation
80
- #
81
- # Reading off the law of the particles at an intermediate time gives the displacement interpolation.
82
- # Under the optimal map X_1 = 3 + 2 X_0 of the Gaussian example, X_t collects into 3t + (1 + t) X_0,
83
- # so rho_t is Gaussian with mean 3t and standard deviation 1 + t. Section 7.7.4.
84
-
85
-
86
- def displacement_law(t: float) -> tuple[float, float]:
87
- """The mean and standard deviation of rho_t for the Gaussian pair, both linear in t."""
88
- return 3.0 * t, 1.0 + t # Mean slides from 0 to 3, standard deviation from 1 to 2.
89
-
90
-
91
- def normal_density(x: torch.Tensor, mean: float, std: float) -> torch.Tensor:
92
- """The density of N(mean, std^2) evaluated at every entry of x."""
93
- return torch.exp(-0.5 * ((x - mean) / std) ** 2) / (std * math.sqrt(2.0 * math.pi))
94
-
95
-
96
- def mixture_density(x: torch.Tensor) -> torch.Tensor:
97
- """Half the source density plus half the target density, the other path between the two ends."""
98
- return 0.5 * normal_density(x, MEAN_0, STD_0) + 0.5 * normal_density(x, MEAN_1, STD_1)
99
-
100
-
101
- def gaussian_w2(m0: float, s0: float, m1: float, s1: float) -> float:
102
- """W_2 between two Gaussians on the line, the closed form of Section 7.6."""
103
- return math.sqrt((m0 - m1) ** 2 + (s0 - s1) ** 2)
104
-
105
-
106
- # %% 06. The velocity field behind the displacement path
107
- #
108
- # Each particle obeys X_t = 3t + (1 + t) X_0 with the constant velocity 3 + X_0. Solving the
109
- # trajectory for X_0 and substituting makes the velocity a function of the current position alone.
110
- # Section 7.7.5.
111
-
112
-
113
- def velocity_field(x: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
114
- """v_t(x) = 3 + (x - 3t) / (1 + t), the mean speed plus a stretch about the mean."""
115
- return 3.0 + (x - 3.0 * t) / (1.0 + t) # Broadcasts over a grid of states and times.
116
-
117
-
118
- def continuity_residual(x: float, t: float) -> float:
119
- """The left side of d_t rho_t + d_x (rho_t v_t) = 0, computed by automatic differentiation."""
120
- xs = torch.tensor(x, dtype=DTYPE, requires_grad=True)
121
- ts = torch.tensor(t, dtype=DTYPE, requires_grad=True)
122
- rho = normal_density(xs, 3.0 * ts, 1.0 + ts) # rho_t written with the two linear schedules.
123
- flux = rho * velocity_field(xs, ts) # The probability flux rho_t v_t.
124
- d_rho_dt = torch.autograd.grad(rho, ts, retain_graph=True)[0] # Keep the graph for the flux.
125
- d_flux_dx = torch.autograd.grad(flux, xs)[0]
126
- return float((d_rho_dt + d_flux_dx).detach())
127
-
128
-
129
- def action_density(t: float, levels: int) -> float:
130
- """The integral of rho_t(x) v_t(x)^2 over x, which is E[(3 + X_0)^2] at every time."""
131
- mean, std = displacement_law(t)
132
- q = (torch.arange(levels, dtype=DTYPE) + 0.5) / levels # Quantile levels of rho_t.
133
- x = mean + std * torch.special.ndtri(q) # [levels] a stratified sample of rho_t.
134
- speed = velocity_field(x, torch.tensor(t, dtype=DTYPE)) # [levels].
135
- return float((speed ** 2).mean()) # The average of the squared speed under rho_t.
136
-
137
-
138
- # %% 07. The report
139
- def main() -> None:
140
- parser = common_parser(__doc__.split("\n")[0])
141
- parser.set_defaults(steps=1024) # --steps is the number of times on the grid.
142
- args = parser.parse_args()
143
- set_seed(args.seed)
144
- loud = not args.quiet
145
- n = max(args.steps, 2)
146
- times = torch.linspace(0.0, 1.0, n + 1, dtype=DTYPE) # [n+1] the time grid.
147
-
148
- steady = action_of_a_path(steady_particle(times), times)
149
- dawdle = action_of_a_path(dawdling_particle(times), times)
150
- if loud:
151
- print(f"One particle from 0 to 2 on a grid of {n} steps")
152
- print(f" constant speed 2: action {steady:.3f}, squared displacement {2.0 ** 2:.0f}")
153
- print(f" waiting then speed 4: action {dawdle:.3f}, the same two endpoints")
154
-
155
- x = torch.tensor([0.0, 2.0], dtype=DTYPE) # Four points, mass 1/2 each.
156
- y = torch.tensor([1.0, 3.0], dtype=DTYPE)
157
- ordered = torch.tensor([[0.5, 0.0], [0.0, 0.5]], dtype=DTYPE)
158
- crossed = torch.tensor([[0.0, 0.5], [0.5, 0.0]], dtype=DTYPE)
159
- independent = torch.full((2, 2), 0.25, dtype=DTYPE)
160
- w22_points = average_squared_displacement(x, y, ordered) # The optimal coupling on the line.
161
- if loud:
162
- print(f"\nAveraging over particles, four points with mass 1/2")
163
- for name, pi in (("ordered", ordered), ("independent", independent), ("crossed", crossed)):
164
- print(f" {name:<12s} E||x_1 - x_0||^2 = "
165
- f"{average_squared_displacement(x, y, pi):.1f} >= W_2^2 = {w22_points:.1f}")
166
-
167
- # Straight lines under the optimal coupling, with the endpoints of the Gaussian example.
168
- q = (torch.arange(n, dtype=DTYPE) + 0.5) / n
169
- x0 = MEAN_0 + STD_0 * torch.special.ndtri(q) # [n] a stratified sample of N(0, 1).
170
- x1 = MEAN_1 + (STD_1 / STD_0) * (x0 - MEAN_0) # The Brenier map 3 + 2x, the optimal pairing.
171
- paths = straight_path(x0[None, :], x1[None, :], times[:, None]) # [n+1, n] every trajectory.
172
- speeds = (paths[1:] - paths[:-1]) / (times[1:] - times[:-1])[:, None] # [n, n] piecewise speed.
173
- action = float((speeds ** 2 * (times[1:] - times[:-1])[:, None]).sum(dim=0).mean())
174
- squared_displacement = float(((x1 - x0) ** 2).mean())
175
- if loud:
176
- print("\nStraight lines under the optimal coupling")
177
- print(f" action averaged over the particles {action:.4f} "
178
- f"average squared displacement {squared_displacement:.4f}")
179
-
180
- mean_half, std_half = displacement_law(0.5)
181
- grid = torch.linspace(-6.0, 9.0, 15001, dtype=DTYPE) # A dense grid for the two densities.
182
- mixture = mixture_density(grid)
183
- peak = float(grid[int(mixture.argmax())])
184
- interpolated = normal_density(grid, mean_half, std_half)
185
- separation = float(0.5 * (interpolated - mixture).abs().sum() * (grid[1] - grid[0]))
186
- half_distance = gaussian_w2(MEAN_0, STD_0, mean_half, std_half)
187
- whole_distance = gaussian_w2(MEAN_0, STD_0, MEAN_1, STD_1)
188
- if loud:
189
- print(f"\nAt t = 1/2 the displacement interpolation is N({mean_half:.1f}, "
190
- f"{std_half ** 2:.2f}) with standard deviation {std_half:.1f}")
191
- print(f" the mixture of the two ends peaks at x = {peak:.2f} and is not a Gaussian")
192
- print(f" total variation between the two candidate densities {separation:.3f}")
193
- print(f" W_2(rho_0, rho_1/2) = {half_distance:.3f} = "
194
- f"{half_distance / whole_distance:.1f} x W_2(mu, nu) = {whole_distance:.3f}")
195
-
196
- mean_speed = float(velocity_field(torch.tensor(3.0 * 0.5, dtype=DTYPE),
197
- torch.tensor(0.5, dtype=DTYPE))) # A particle at the mean.
198
- above_speed = float(velocity_field(torch.tensor(3.0 * 0.5 + 1.5, dtype=DTYPE),
199
- torch.tensor(0.5, dtype=DTYPE))) # One standard deviation up.
200
- residual = max(abs(continuity_residual(xx, tt))
201
- for xx in (-1.0, 0.0, 1.5, 4.0) for tt in (0.1, 0.5, 0.9))
202
- densities = [action_density(float(t), 2048) for t in torch.linspace(0.0, 1.0, 5, dtype=DTYPE)]
203
- landing = x0 + 1.0 * velocity_field(x0, torch.zeros((), dtype=DTYPE)) # One Euler step of size 1.
204
- one_step = float((landing - x1).abs().max()) # The step lands on the Brenier map 3 + 2 x_0.
205
- if loud:
206
- print(f"\nv_t(x) = 3 + (x - 3t) / (1 + t)")
207
- print(f" at the mean, speed {mean_speed:.0f}; one standard deviation above, "
208
- f"speed {above_speed:.0f}")
209
- print(f" action density at five times {[round(d, 4) for d in densities]}")
210
- print(f" largest continuity residual on the test states {residual:.2e}")
211
-
212
- report = {
213
- "action of the steady particle": steady,
214
- "action of the dawdling particle": dawdle,
215
- "squared displacement of both": 4.0,
216
- "E||x1-x0||^2, ordered": average_squared_displacement(x, y, ordered),
217
- "E||x1-x0||^2, independent": average_squared_displacement(x, y, independent),
218
- "E||x1-x0||^2, crossed": average_squared_displacement(x, y, crossed),
219
- "action of the straight paths": action,
220
- "W_2^2 of the Gaussian pair": whole_distance ** 2,
221
- "rho_1/2 mean, standard deviation": f"{mean_half:.1f}, {std_half:.1f}",
222
- "peak of the mixture": peak,
223
- "total variation of the two paths": separation,
224
- "W_2(rho_0, rho_1/2)": half_distance,
225
- "W_2(mu, nu)": whole_distance,
226
- "speed at the mean": mean_speed,
227
- "speed one deviation above": above_speed,
228
- "action density, every time": max(densities),
229
- "continuity equation residual": residual,
230
- "one Euler step of size 1 minus T": one_step,
231
- }
232
- print_report("Transport as motion, Chapter 7 Sections 7.7 to 7.7.5", report)
233
- save_run(args.out, config=vars(args), report=report) # Written only with --out.
234
-
235
-
236
- if __name__ == "__main__":
237
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_7/dsb.py DELETED
@@ -1,204 +0,0 @@
1
- """Diffusion Schrodinger Bridge: iterative proportional fitting run on path laws.
2
-
3
- Each half-step of proportional fitting is a relative-entropy projection onto the set of path laws
4
- with one marginal correct, and the chain rule solves it in closed form by keeping the conditional
5
- law of the previous iterate and resetting that one marginal. The conditional law is the current
6
- process run backward in time, so a half-step amounts to reversing the current process and
7
- restarting it from the requested law at the other end.
8
-
9
- Chapter 7, Section 7.19.1 of the notes. Runs the half-steps exactly on the two-state chain with
10
- transition matrix A, source law (0.5, 0.5) and requested terminal law (0.2, 0.8), and checks the
11
- diffusion time reversal in closed form on a Gaussian. Prints the backward matrices, the law after
12
- each half-step, the forward matrices of the first round and the error at the free end.
13
-
14
- python lecture_7/dsb.py # 4 rounds, the first one worked by hand
15
- python lecture_7/dsb.py --steps 1 # the single round the chapter writes out
16
- """
17
-
18
- # %% 01. Imports and configuration
19
- from __future__ import annotations
20
-
21
- import math
22
-
23
- import torch
24
-
25
- from cis6270.runner import common_parser, print_report, save_run, set_seed
26
-
27
- DTYPE = torch.float64 # The chapter quotes six figures of the backward and forward matrices.
28
-
29
- A = torch.tensor([[0.7, 0.3], [0.4, 0.6]], dtype=DTYPE) # The reference transition matrix.
30
- PI_0 = torch.tensor([0.5, 0.5], dtype=DTYPE) # The source law.
31
- PI_1 = torch.tensor([0.2, 0.8], dtype=DTYPE) # The requested terminal law.
32
- TILTED_0 = torch.tensor([[0.599160, 0.400840], [0.299266, 0.700734]], dtype=DTYPE) # A^h_0.
33
- TILTED_1 = torch.tensor([[0.307297, 0.692703], [0.112490, 0.887510]], dtype=DTYPE) # A^h_1.
34
-
35
-
36
- # %% 02. A path law as a joint table
37
- #
38
- # With two states and two steps a path law is a table over the triple (x_0, x_1, x_2), so every
39
- # projection below is an exact operation on eight numbers. Section 7.12.1.
40
-
41
-
42
- def reference_path_law(start: torch.Tensor, matrix: torch.Tensor, steps: int) -> torch.Tensor:
43
- """R(x_{0:N}) as a tensor with one axis per time, from the starting law and one matrix per step."""
44
- law = start.clone() # [K] the law at time 0.
45
- for _ in range(steps):
46
- law = law.unsqueeze(-1) * matrix # Multiply by the transition matrix on a new axis.
47
- return law # [K] * (N + 1).
48
-
49
-
50
- def marginal(law: torch.Tensor, axis: int) -> torch.Tensor:
51
- """The law of the state at one time, summed over every other time."""
52
- other = [d for d in range(law.dim()) if d != axis]
53
- return law.sum(dim=other) # [K].
54
-
55
-
56
- def one_step_conditional(law: torch.Tensor, step: int) -> torch.Tensor:
57
- """P(x_{k+1} | x_k), the forward transition matrix the path law carries at that step."""
58
- pair = law.sum(dim=[d for d in range(law.dim()) if d not in (step, step + 1)]) # [K, K].
59
- return pair / pair.sum(dim=1, keepdim=True) # Normalize each row.
60
-
61
-
62
- def reverse_step(law: torch.Tensor, step: int) -> torch.Tensor:
63
- """P(x_k | x_{k+1}), Bayes' rule on one step, which is the reversal a network has to learn."""
64
- pair = law.sum(dim=[d for d in range(law.dim()) if d not in (step, step + 1)]) # [K, K].
65
- return (pair / pair.sum(dim=0, keepdim=True)).T # [K, K] indexed by the later state first.
66
-
67
-
68
- # %% 03. The two half-steps
69
- #
70
- # The odd iterate keeps the conditional law of the previous iterate given the final state and
71
- # replaces the marginal at time 1, and the even iterate does the same at time 0. Section 7.19.1.
72
-
73
-
74
- def fix_terminal(law: torch.Tensor, end: torch.Tensor) -> torch.Tensor:
75
- """P'(x_{0:N}) = pi_1(x_N) P(x_{0:N-1} | x_N), the projection onto the laws ending at pi_1."""
76
- last = marginal(law, law.dim() - 1) # [K] the terminal marginal of the current law.
77
- return law * (end / last) # Reweight every path by the ratio at its final state.
78
-
79
-
80
- def fix_initial(law: torch.Tensor, start: torch.Tensor) -> torch.Tensor:
81
- """P''(x_{0:N}) = pi_0(x_0) P'(x_{1:N} | x_0), the projection onto the laws starting at pi_0."""
82
- first = marginal(law, 0) # [K] the initial marginal of the current law.
83
- shape = [-1] + [1] * (law.dim() - 1) # Broadcast the ratio along the first axis.
84
- return law * (start / first).reshape(shape)
85
-
86
-
87
- def proportional_fitting(law: torch.Tensor, start: torch.Tensor, end: torch.Tensor,
88
- rounds: int) -> tuple[torch.Tensor, list[float], list[torch.Tensor]]:
89
- """Alternate the two half-steps, returning the law, the terminal error and the law each round."""
90
- errors: list[float] = []
91
- iterates: list[torch.Tensor] = []
92
- for _ in range(rounds):
93
- law = fix_terminal(law, end) # One half-step: the terminal marginal is now exact.
94
- law = fix_initial(law, start) # The other: the initial marginal is now exact.
95
- errors.append(float((marginal(law, law.dim() - 1) - end).abs().sum()))
96
- iterates.append(law.clone())
97
- return law, errors, iterates
98
-
99
-
100
- # %% 04. The reversal that a network learns
101
- #
102
- # On a finite state space the reversal is Bayes' rule on a matrix. On R^d it is the time reversal of
103
- # a diffusion, whose drift is minus the forward drift plus epsilon times the score of the current
104
- # marginal, and the score of the current iterate has no closed form. Section 7.19.1.
105
-
106
-
107
- def reverse_drift(x: torch.Tensor, t: torch.Tensor, epsilon: float, variance_0: float
108
- ) -> torch.Tensor:
109
- """beta(x, t) = -b + epsilon grad log p_t for a driftless diffusion started from N(0, v_0)."""
110
- variance = variance_0 + epsilon * t # The marginal of sqrt(epsilon) dB started from N(0, v_0).
111
- return epsilon * (-x / variance) # The score of a centered Gaussian is -x over its variance.
112
-
113
-
114
- def reversal_check(epsilon: float, variance_0: float, steps: int, paths: int,
115
- generator: torch.Generator) -> tuple[float, float]:
116
- """Run the reversal backward from p_1 and compare its variance at time 0 with v_0."""
117
- dt = 1.0 / steps
118
- variance_1 = variance_0 + epsilon * 1.0 # The forward marginal at time 1.
119
- x = torch.randn(paths, dtype=DTYPE, generator=generator) * math.sqrt(variance_1)
120
- for step in range(steps):
121
- t = torch.tensor(1.0 - step * dt, dtype=DTYPE) # Time runs down from 1 to 0.
122
- noise = torch.randn(paths, dtype=DTYPE, generator=generator) * math.sqrt(dt)
123
- x = x + reverse_drift(x, t, epsilon, variance_0) * dt + math.sqrt(epsilon) * noise
124
- return float(x.var()), variance_0
125
-
126
-
127
- # %% 05. The report
128
- def main() -> None:
129
- parser = common_parser(__doc__.split("\n")[0])
130
- parser.set_defaults(steps=4) # --steps is the number of proportional fitting rounds.
131
- args = parser.parse_args()
132
- set_seed(args.seed)
133
- loud = not args.quiet
134
- rounds = max(args.steps, 1)
135
- generator = torch.Generator().manual_seed(args.seed)
136
-
137
- reference = reference_path_law(PI_0, A, 2) # [2, 2, 2] the law P^0 = R.
138
- marginals = [marginal(reference, k) for k in range(3)]
139
- B2 = reverse_step(reference, 1) # P(x_1 | x_2), the reversal of the last step.
140
- B1 = reverse_step(reference, 0) # P(x_0 | x_1), the reversal of the earlier step.
141
- if loud:
142
- print("The reference started from pi_0, read at the three time points")
143
- print(f" {[[round(float(z), 3) for z in m] for m in marginals]}")
144
- print(f" B_2 = {[[round(float(z), 6) for z in row] for row in B2]}")
145
- print(f" B_1(1,1) = {float(B1[0, 0]):.6f}")
146
-
147
- half = fix_terminal(reference, PI_1) # The first half-step, which resets the terminal marginal.
148
- backward_laws = [marginal(half, 2), marginal(half, 1), marginal(half, 0)]
149
- drift_at_zero = float((backward_laws[2] - PI_0).abs().sum())
150
- full = fix_initial(half, PI_0) # The second half-step, which resets the initial marginal.
151
- F0 = one_step_conditional(full, 0)
152
- F1 = one_step_conditional(full, 1)
153
- terminal = marginal(full, 2)
154
- terminal_error = float((terminal - PI_1).abs().sum())
155
- tilt_gap = max(float((F0 - TILTED_0).abs().max()), float((F1 - TILTED_1).abs().max()))
156
- if loud:
157
- print("\nThe backward chain started from the requested terminal law")
158
- written = ["(" + ", ".join(f"{float(z):.6f}" for z in m) + ")" for m in backward_laws]
159
- print(f" {' -> '.join(written)}")
160
- print(f" total absolute error at step 0: {drift_at_zero:.4f}")
161
- print(f"\nThe forward matrices of the second half-step")
162
- print(f" F_0 = {[[round(float(z), 6) for z in row] for row in F0]}")
163
- print(f" F_1 = {[[round(float(z), 6) for z in row] for row in F1]}")
164
- print(f" terminal law {[round(float(z), 6) for z in terminal]}, "
165
- f"off by {terminal_error:.4f}")
166
- print(f" largest gap against the tilted matrices A^h_0 and A^h_1: {tilt_gap:.4f}")
167
-
168
- _, errors, _ = proportional_fitting(reference, PI_0, PI_1, rounds)
169
- ratios = [errors[k] / errors[k + 1] for k in range(len(errors) - 1) if errors[k + 1] > 0]
170
- if loud:
171
- print(f"\nterminal error by round {['%.3e' % e for e in errors]}")
172
- print(f" ratios {[round(r, 1) for r in ratios]}")
173
-
174
- simulated, exact = reversal_check(1.0, 1.0, 200, 8192, generator)
175
- if loud:
176
- print(f"\nThe diffusion reversal on a Gaussian, run backward from p_1")
177
- print(f" variance at time 0: simulated {simulated:.3f}, exact {exact:.3f}")
178
-
179
- report = {
180
- "reference marginal at step 1": [round(float(z), 3) for z in marginals[1]],
181
- "reference marginal at step 2": [round(float(z), 3) for z in marginals[2]],
182
- "B_2(1,1)": float(B2[0, 0]),
183
- "B_2(2,1)": float(B2[1, 0]),
184
- "B_1(1,1)": float(B1[0, 0]),
185
- "backward law after one step": [round(float(z), 6) for z in backward_laws[1]],
186
- "backward law at step 0": [round(float(z), 6) for z in backward_laws[2]],
187
- "error at the free end, half-step": drift_at_zero,
188
- "F_0(1,1)": float(F0[0, 0]),
189
- "F_1(1,2)": float(F1[0, 1]),
190
- "terminal law after one round": [round(float(z), 6) for z in terminal],
191
- "terminal error after one round": terminal_error,
192
- "gap against the tilted matrices": tilt_gap,
193
- "terminal error, last round": errors[-1],
194
- "error ratio per round": ratios[0] if ratios else float("nan"),
195
- "rounds run": rounds,
196
- "reversal variance, simulated": simulated,
197
- "reversal variance, exact": exact,
198
- }
199
- print_report("Diffusion Schrodinger Bridge, Chapter 7 Section 7.19.1", report)
200
- save_run(args.out, config=vars(args), report=report) # Written only with --out.
201
-
202
-
203
- if __name__ == "__main__":
204
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_7/dsbm.py DELETED
@@ -1,207 +0,0 @@
1
- """Diffusion Schrodinger Bridge Matching: the Markov projection and the reciprocal projection.
2
-
3
- A mixture of bridges draws a pair of ends from a coupling and runs the reference bridge between
4
- them, so its drift depends on the endpoint and the law is in general not Markov. Averaging that
5
- drift over the endpoints of all bridges through a state removes the extra information and leaves a
6
- diffusion with the same marginals, and replacing the bridges of a Markov law by reference bridges
7
- keeps its endpoint coupling. Alternating the two projections holds both marginals fixed throughout
8
- and improves only the pairing.
9
-
10
- Chapter 7, Section 7.19.2 of the notes. Runs the two projections exactly on the two-state chain,
11
- started from the independent coupling of (0.5, 0.5) and (0.2, 0.8). Prints the averaged drift at one
12
- point, the two projected matrices of the first round, and the gap against the bridge coupling.
13
-
14
- python lecture_7/dsbm.py # 9 rounds, which reach 1e-9 on this problem
15
- python lecture_7/dsbm.py --steps 1 # the single round the chapter writes out
16
- """
17
-
18
- # %% 01. Imports and configuration
19
- from __future__ import annotations
20
-
21
- import torch
22
-
23
- from cis6270.runner import common_parser, print_report, save_run, set_seed
24
-
25
- DTYPE = torch.float64 # The chapter quotes six figures of the two projected matrices.
26
-
27
- A = torch.tensor([[0.7, 0.3], [0.4, 0.6]], dtype=DTYPE) # The reference transition matrix.
28
- PI_0 = torch.tensor([0.5, 0.5], dtype=DTYPE) # The source law.
29
- PI_1 = torch.tensor([0.2, 0.8], dtype=DTYPE) # The requested terminal law.
30
-
31
-
32
- # %% 02. The averaged bridge drift
33
- #
34
- # Two bridges passing through the same state at the same time carry different drifts, and the
35
- # Markov drift at that state is their average weighted by how often each one passes through.
36
- # A least-squares fit of a single number against the two targets lands on the same value.
37
- # Section 7.19.2.
38
-
39
-
40
- def bridge_drift(x1: float, x: float, t: float) -> float:
41
- """(x_1 - x) / (1 - t), the drift of the Brownian bridge ending at x_1."""
42
- return (x1 - x) / (1.0 - t)
43
-
44
-
45
- def markov_drift(endpoints: torch.Tensor, weights: torch.Tensor, x: float, t: float) -> float:
46
- """v*(x, t) = E[(X_1 - x) / (1 - t) | X_t = x], the average over the bridges through x."""
47
- drifts = (endpoints - x) / (1.0 - t) # [n] one conditional drift per endpoint.
48
- return float((weights * drifts).sum()) # The posterior average of the conditional drifts.
49
-
50
-
51
- def least_squares_derivative(a: float, endpoints: torch.Tensor, weights: torch.Tensor,
52
- x: float, t: float) -> float:
53
- """The derivative in a of the average squared error of a single prediction against the drifts."""
54
- drifts = (endpoints - x) / (1.0 - t) # [n].
55
- return float(2.0 * (weights * (a - drifts)).sum()) # Vanishes at the weighted average.
56
-
57
-
58
- # %% 03. The reciprocal projection
59
- #
60
- # A member of the reciprocal class is an endpoint coupling filled in with reference bridges. The
61
- # projection of a Markov law keeps that law's endpoint coupling and redraws the interior from the
62
- # reference. Section 7.19.2.
63
-
64
-
65
- def reference_bridge_table(matrix: torch.Tensor) -> torch.Tensor:
66
- """R(x_1 | x_0, x_2) = A(x_0, x_1) A(x_1, x_2) / A^2(x_0, x_2) for every triple. [K, K, K]."""
67
- two_step = matrix @ matrix # [K, K] the reference over both steps.
68
- joint = matrix[:, :, None] * matrix[None, :, :] # [x_0, x_1, x_2] the unnormalized weight.
69
- return joint / two_step[:, None, :] # Normalize over the middle state.
70
-
71
-
72
- def reciprocal_projection(coupling: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
73
- """proj_R(M) = M_{0,1} R_{.|0,1}, the coupling with the reference bridges put back in."""
74
- return coupling[:, None, :] * reference_bridge_table(matrix) # [K, K, K] a full path law.
75
-
76
-
77
- # %% 04. The Markov projection
78
- #
79
- # Retaining the one-step conditionals of a path law and multiplying them into a chain gives the
80
- # Markov law with the same marginals, and the averaging in each conditional is the discrete form of
81
- # the drift average. Section 7.19.2.
82
-
83
-
84
- def one_step_conditional(law: torch.Tensor, step: int) -> torch.Tensor:
85
- """P(x_{k+1} | x_k) read off a path law, which is what the network fits at that transition."""
86
- pair = law.sum(dim=[d for d in range(law.dim()) if d not in (step, step + 1)]) # [K, K].
87
- return pair / pair.sum(dim=1, keepdim=True)
88
-
89
-
90
- def markov_projection(law: torch.Tensor) -> tuple[list[torch.Tensor], torch.Tensor]:
91
- """The chain of one-step conditionals, and the endpoint coupling it produces from pi_0."""
92
- conditionals = [one_step_conditional(law, k) for k in range(law.dim() - 1)]
93
- joint = torch.diag(marginal(law, 0)) # [K, K] the initial law on the diagonal.
94
- for matrix in conditionals:
95
- joint = joint @ matrix # Multiply the conditionals into a chain.
96
- return conditionals, joint
97
-
98
-
99
- def marginal(law: torch.Tensor, axis: int) -> torch.Tensor:
100
- """The law of the state at one time, summed over every other time."""
101
- return law.sum(dim=[d for d in range(law.dim()) if d != axis])
102
-
103
-
104
- # %% 05. Alternating the two projections
105
- #
106
- # Under a Markov projection every marginal is unchanged and under a reciprocal projection the
107
- # endpoint coupling is unchanged, so both pi_0 and pi_1 stay exact once the starting law has them,
108
- # and the iterations improve only the coupling. Section 7.19.2.
109
-
110
-
111
- def imf_rounds(coupling: torch.Tensor, matrix: torch.Tensor, rounds: int,
112
- target: torch.Tensor) -> tuple[torch.Tensor, list[float], list]:
113
- """Run iterative Markovian fitting, returning the coupling, the gaps and the first round."""
114
- gaps: list[float] = []
115
- first: list = []
116
- for index in range(rounds):
117
- law = reciprocal_projection(coupling, matrix) # Reference bridges between the current ends.
118
- conditionals, coupling = markov_projection(law) # The Markov law and its endpoint coupling.
119
- gaps.append(float((coupling - target).abs().sum()))
120
- if index == 0:
121
- first = conditionals
122
- return coupling, gaps, first
123
-
124
-
125
- def bridge_coupling(matrix: torch.Tensor, start: torch.Tensor, end: torch.Tensor,
126
- rounds: int = 200) -> torch.Tensor:
127
- """The exact bridge coupling, by scaling diag(pi_0) A^2 to the two marginals."""
128
- R01 = torch.diag(start) @ torch.linalg.matrix_power(matrix, 2)
129
- f = torch.ones_like(start)
130
- for _ in range(rounds):
131
- g = end / (R01.T @ f)
132
- f = start / (R01 @ g)
133
- return f[:, None] * R01 * g[None, :]
134
-
135
-
136
- # %% 06. The report
137
- def main() -> None:
138
- parser = common_parser(__doc__.split("\n")[0])
139
- parser.set_defaults(steps=9) # --steps is the number of Markovian fitting rounds.
140
- args = parser.parse_args()
141
- set_seed(args.seed)
142
- loud = not args.quiet
143
- rounds = max(args.steps, 1)
144
-
145
- endpoints = torch.tensor([3.0, 0.0], dtype=DTYPE) # Two bridges through x = 1 at t = 0.5.
146
- weights = torch.tensor([0.5, 0.5], dtype=DTYPE) # They pass with equal probability.
147
- average = markov_drift(endpoints, weights, 1.0, 0.5)
148
- if loud:
149
- print("Two bridges through x = 1 at t = 0.5, ending at 3 and at 0")
150
- print(f" their drifts {bridge_drift(3.0, 1.0, 0.5):.0f} and "
151
- f"{bridge_drift(0.0, 1.0, 0.5):.0f}, average {average:.0f}")
152
- print(f" derivative of the squared error at a = {average:.0f}: "
153
- f"{least_squares_derivative(average, endpoints, weights, 1.0, 0.5):.0f}")
154
- print(f" derivative at a = 0: "
155
- f"{least_squares_derivative(0.0, endpoints, weights, 1.0, 0.5):+.0f}")
156
-
157
- independent = PI_0[:, None] * PI_1[None, :] # [[0.1, 0.4], [0.1, 0.4]].
158
- target = bridge_coupling(A, PI_0, PI_1)
159
- bridges = reference_bridge_table(A)
160
- starting_gap = float((independent - target).abs().sum())
161
- coupling, gaps, first = imf_rounds(independent, A, rounds, target)
162
- projected = torch.diag(PI_0) @ first[0] @ first[1]
163
- ratios = [gaps[k] / gaps[k + 1] for k in range(len(gaps) - 1) if gaps[k + 1] > 0]
164
- if loud:
165
- print(f"\nStarting from the independent coupling "
166
- f"{[[round(float(z), 1) for z in row] for row in independent]}")
167
- print(f" the middle state is 1 with probability {float(bridges[0, 0, 0]):.6f} "
168
- f"from 1 to 1, and {float(bridges[0, 0, 1]):.6f} from 1 to 2")
169
- print(f" M_0 = {[[round(float(z), 6) for z in row] for row in first[0]]}")
170
- print(f" M_1 = {[[round(float(z), 6) for z in row] for row in first[1]]}")
171
- print(f" M_0(1,1) = 0.2 x {float(bridges[0, 0, 0]):.6f} + 0.8 x "
172
- f"{float(bridges[0, 0, 1]):.6f} = {float(first[0][0, 0]):.6f}")
173
- print(f" diag(pi_0) M_0 M_1 = "
174
- f"{[[round(float(z), 6) for z in row] for row in projected]}")
175
- print(f" row sums {[round(float(z), 3) for z in projected.sum(1)]}, "
176
- f"column sums {[round(float(z), 3) for z in projected.sum(0)]}")
177
- print(f"\ngap against pi*: {starting_gap:.4f} at the start, "
178
- f"then {['%.2e' % g for g in gaps[:5]]}")
179
- print(f" ratios {[round(r, 2) for r in ratios[:4]]}")
180
-
181
- reached = next((k + 1 for k, g in enumerate(gaps) if g < 1e-9), None)
182
- report = {
183
- "drift of the bridge ending at 3": bridge_drift(3.0, 1.0, 0.5),
184
- "drift of the bridge ending at 0": bridge_drift(0.0, 1.0, 0.5),
185
- "averaged Markov drift": average,
186
- "least squares derivative at a = 1": least_squares_derivative(
187
- average, endpoints, weights, 1.0, 0.5),
188
- "R(mid = 1 | 1, 1)": float(bridges[0, 0, 0]),
189
- "R(mid = 1 | 1, 2)": float(bridges[0, 0, 1]),
190
- "M_0(1,1)": float(first[0][0, 0]),
191
- "M_1(1,2)": float(first[1][0, 1]),
192
- "endpoint coupling, entry 11": float(projected[0, 0]),
193
- "endpoint coupling, entry 12": float(projected[0, 1]),
194
- "row sums after the projection": [round(float(z), 6) for z in projected.sum(dim=1)],
195
- "column sums after the projection": [round(float(z), 6) for z in projected.sum(dim=0)],
196
- "gap, independent coupling": starting_gap,
197
- "gap after one round": gaps[0],
198
- "gap after the last round": gaps[-1],
199
- "gap ratio per round": ratios[-1] if ratios else float("nan"),
200
- "rounds to 1e-9": reached if reached else rounds,
201
- }
202
- print_report("Diffusion Schrodinger Bridge Matching, Chapter 7 Section 7.19.2", report)
203
- save_run(args.out, config=vars(args), report=report) # Written only with --out.
204
-
205
-
206
- if __name__ == "__main__":
207
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_7/dynamic_bridge.py DELETED
@@ -1,257 +0,0 @@
1
- """A diffusion reference: Girsanov, the control problem, Hopf-Cole, and the Brownian bridge.
2
-
3
- Changing the drift of a diffusion and keeping its noise is the only change under which the two path
4
- laws stay comparable, and over one short step the two increments differ only in their mean. Summing
5
- those steps gives Girsanov's formula, averaging it under the controlled law turns relative entropy
6
- on paths into an integral of a squared drift, and the Hopf-Cole change of variables turns the
7
- resulting Hamilton-Jacobi-Bellman equation into the linear backward equation of the reference.
8
-
9
- Chapter 7, Sections 7.16 to 7.18 of the notes. Runs on a driftless diffusion in one dimension.
10
- Prints the two computations of the relative entropy of a constant control, the residual of the
11
- backward equation and of the Hamilton-Jacobi-Bellman equation, and the Brownian bridge between two
12
- fixed points.
13
-
14
- python lecture_7/dynamic_bridge.py # 200 Euler-Maruyama steps
15
- python lecture_7/dynamic_bridge.py --steps 20 # a coarse time grid, for a first look
16
- """
17
-
18
- # %% 01. Imports and configuration
19
- from __future__ import annotations
20
-
21
- import math
22
-
23
- import torch
24
-
25
- from cis6270.runner import common_parser, print_report, save_run, set_seed
26
-
27
- DTYPE = torch.float64 # Closed forms and a Monte Carlo check, both in double precision.
28
-
29
- EPSILON = 1.0 # The reference noise variance per unit time.
30
- START, END = 0.0, 2.0 # The two points the bridge is pinned at.
31
- PATHS = 8192 # Trajectories in the Monte Carlo checks.
32
-
33
-
34
- # %% 02. One short step
35
- #
36
- # Over a step of length dt both increments are Gaussian with the covariance epsilon dt, and they
37
- # differ only in the mean, by u dt. The logarithm of the ratio of the two densities is the inner
38
- # product of w, the increment beyond the reference drift, with u over epsilon, minus the squared
39
- # norm of u times dt over 2 epsilon. Section 7.16.1.
40
-
41
-
42
- def log_density_ratio_step(w: torch.Tensor, u: torch.Tensor, dt: float,
43
- epsilon: float = EPSILON) -> torch.Tensor:
44
- """log p^u / p^R over one step, with w = Delta X - b dt the part beyond the reference drift."""
45
- return (w * u).sum(dim=-1) / epsilon - (u ** 2).sum(dim=-1) * dt / (2.0 * epsilon)
46
-
47
-
48
- # %% 03. Summing over the steps, and the average under the controlled law
49
- #
50
- # Adding the per-step contributions and letting dt go to zero gives Girsanov's formula, a martingale
51
- # term built from the noise and a drift term built from the size of the control. Averaging under
52
- # P^u leaves the two energy terms with coefficients 1/epsilon and -1/(2 epsilon).
53
- # Sections 7.16.2 and 7.16.3.
54
-
55
-
56
- def simulate_controlled(u: float, steps: int, paths: int, epsilon: float = EPSILON,
57
- generator: torch.Generator | None = None) -> tuple[torch.Tensor, torch.Tensor]:
58
- """Euler-Maruyama for dX = u dt + sqrt(epsilon) dB, returning the endpoints and the log ratio.
59
-
60
- The log ratio is accumulated step by step from the same Brownian increments the path uses, which
61
- is how Section 7.27.3 accumulates it during training.
62
- """
63
- dt = 1.0 / steps
64
- x = torch.zeros(paths, 1, dtype=DTYPE) # [P, 1] every path starts at the origin.
65
- log_ratio = torch.zeros(paths, dtype=DTYPE) # [P] the Girsanov integral along each path.
66
- control = torch.full((paths, 1), u, dtype=DTYPE) # [P, 1] a control constant in space and time.
67
- for _ in range(steps):
68
- noise = torch.randn(paths, 1, dtype=DTYPE, generator=generator) * math.sqrt(dt) # dB.
69
- w = control * dt + math.sqrt(epsilon) * noise # Delta X with no reference drift.
70
- log_ratio = log_ratio + log_density_ratio_step(w, control, dt, epsilon)
71
- x = x + w # The controlled state after this step.
72
- return x.squeeze(1), log_ratio
73
-
74
-
75
- def control_energy_entropy(u: float, epsilon: float = EPSILON) -> float:
76
- """KL(P^u || R) = (1 / 2 epsilon) E int_0^1 ||u||^2 dt, for a control constant in time."""
77
- return u ** 2 / (2.0 * epsilon) # The integral of a constant over the unit interval.
78
-
79
-
80
- def gaussian_entropy_gap(shift: float, variance: float) -> float:
81
- """KL between two Gaussians with a common variance, the squared difference of the means over 2v."""
82
- return shift ** 2 / (2.0 * variance)
83
-
84
-
85
- # %% 04. The value function and the optimal control
86
- #
87
- # Bellman's principle and Ito's formula give the Hamilton-Jacobi-Bellman equation, in which only two
88
- # terms contain the control. Completing the square in those two isolates u* = -grad V, and
89
- # substituting it back leaves a closed equation for V with a squared gradient in it.
90
- # Sections 7.17 to 7.17.2.
91
-
92
-
93
- def optimal_control(value, x: float, t: float) -> float:
94
- """u*(x, t) = -grad V_t(x), the control pointing downhill on the cost-to-go."""
95
- xs = torch.tensor(x, dtype=DTYPE, requires_grad=True)
96
- v = value(xs, torch.tensor(t, dtype=DTYPE))
97
- return float(-torch.autograd.grad(v, xs)[0])
98
-
99
-
100
- def hjb_residual(value, x: float, t: float, epsilon: float = EPSILON) -> float:
101
- """d_t V + (epsilon / 2) grad^2 V - (1/2) ||grad V||^2, with no reference drift."""
102
- xs = torch.tensor(x, dtype=DTYPE, requires_grad=True)
103
- ts = torch.tensor(t, dtype=DTYPE, requires_grad=True)
104
- v = value(xs, ts)
105
- dv_dt = torch.autograd.grad(v, ts, create_graph=True)[0]
106
- dv_dx = torch.autograd.grad(v, xs, create_graph=True)[0]
107
- d2v_dx2 = torch.autograd.grad(dv_dx, xs, create_graph=True)[0]
108
- return float((dv_dt + 0.5 * epsilon * d2v_dx2 - 0.5 * dv_dx ** 2).detach())
109
-
110
-
111
- # %% 05. Removing the squared gradient
112
- #
113
- # Writing the value function as minus epsilon times a logarithm cancels the squared gradient against
114
- # the extra term the Laplacian produces, and what is left is the Kolmogorov backward equation of the
115
- # reference, which is linear. In the new variable the control is epsilon times the gradient of the
116
- # logarithm of phi. Sections 7.17.3 and 7.17.4.
117
-
118
-
119
- def hopf_cole(potential, epsilon: float = EPSILON):
120
- """V_t = -epsilon log phi_t, the change of variables that linearizes the equation."""
121
- return lambda x, t: -epsilon * torch.log(potential(x, t))
122
-
123
-
124
- def backward_residual(potential, x: float, t: float, epsilon: float = EPSILON) -> float:
125
- """d_t phi + (epsilon / 2) grad^2 phi, which vanishes for a reference transition density."""
126
- xs = torch.tensor(x, dtype=DTYPE, requires_grad=True)
127
- ts = torch.tensor(t, dtype=DTYPE, requires_grad=True)
128
- phi = potential(xs, ts)
129
- dphi_dt = torch.autograd.grad(phi, ts, create_graph=True)[0]
130
- dphi_dx = torch.autograd.grad(phi, xs, create_graph=True)[0]
131
- d2phi_dx2 = torch.autograd.grad(dphi_dx, xs, create_graph=True)[0]
132
- return float((dphi_dt + 0.5 * epsilon * d2phi_dx2).detach())
133
-
134
-
135
- # %% 06. The Brownian bridge
136
- #
137
- # Conditioning on a single final point y makes the potential the reference density of arriving at y
138
- # from the current state, and epsilon times the gradient of its logarithm is the remaining
139
- # displacement over the remaining time. The noise level cancels from the drift and survives in the
140
- # variance. Section 7.18.
141
-
142
-
143
- def bridge_potential(z: torch.Tensor, t: torch.Tensor, y: float = END,
144
- epsilon: float = EPSILON) -> torch.Tensor:
145
- """h(z, t) = R_{1|t}(y | z), the full Gaussian density with variance epsilon (1 - t)."""
146
- remaining = 1.0 - t # The time left, which is the variance divided by epsilon.
147
- return torch.exp(-((y - z) ** 2) / (2.0 * epsilon * remaining)) / torch.sqrt(
148
- 2.0 * math.pi * epsilon * remaining)
149
-
150
-
151
- def bridge_drift(z: torch.Tensor, t: torch.Tensor, y: float = END) -> torch.Tensor:
152
- """(y - z) / (1 - t), the velocity that reaches y on time if nothing further perturbs the state."""
153
- return (y - z) / (1.0 - t)
154
-
155
-
156
- def bridge_law(t: float, x: float = START, y: float = END,
157
- epsilon: float = EPSILON) -> tuple[float, float]:
158
- """The mean (1 - t) x + t y and the variance epsilon t (1 - t) of the bridge at time t."""
159
- return (1.0 - t) * x + t * y, epsilon * t * (1.0 - t)
160
-
161
-
162
- def simulate_bridge(steps: int, paths: int, epsilon: float = EPSILON,
163
- generator: torch.Generator | None = None) -> torch.Tensor:
164
- """Euler-Maruyama for the bridge SDE, returning the state at every time. [steps + 1, P]."""
165
- dt = 1.0 / steps
166
- x = torch.full((paths,), START, dtype=DTYPE)
167
- trajectory = [x.clone()]
168
- for step in range(steps):
169
- t = torch.tensor(step * dt, dtype=DTYPE)
170
- noise = torch.randn(paths, dtype=DTYPE, generator=generator) * math.sqrt(dt)
171
- x = x + bridge_drift(x, t) * dt + math.sqrt(epsilon) * noise # One Euler-Maruyama step.
172
- trajectory.append(x.clone())
173
- return torch.stack(trajectory) # [steps + 1, P].
174
-
175
-
176
- # %% 07. The report
177
- def main() -> None:
178
- parser = common_parser(__doc__.split("\n")[0])
179
- parser.set_defaults(steps=200) # --steps is the number of Euler-Maruyama steps.
180
- args = parser.parse_args()
181
- set_seed(args.seed)
182
- loud = not args.quiet
183
- steps = max(args.steps, 2)
184
- generator = torch.Generator().manual_seed(args.seed)
185
-
186
- endpoints, log_ratio = simulate_controlled(2.0, steps, PATHS, EPSILON, generator)
187
- girsanov = float(log_ratio.mean()) # The average of the log density under P^u.
188
- closed_form = control_energy_entropy(2.0)
189
- endpoint_form = gaussian_entropy_gap(2.0, EPSILON)
190
- if loud:
191
- print(f"A constant control u = 2 at epsilon = {EPSILON:g}, over {steps} steps")
192
- print(f" (1 / 2 eps) int ||u||^2 dt = {closed_form:.3f}")
193
- print(f" KL(N(x + 2, 1) || N(x, 1)) = {endpoint_form:.3f}")
194
- print(f" averaged Girsanov log ratio over {PATHS} paths = {girsanov:.3f}")
195
- print(f" endpoint mean {float(endpoints.mean()):.3f}, "
196
- f"variance {float(endpoints.var()):.3f}")
197
-
198
- value = hopf_cole(bridge_potential) # V_t = -epsilon log h, with h the pinned potential.
199
- test_points = [(-0.5, 0.2), (0.5, 0.5), (1.5, 0.5), (2.5, 0.8)]
200
- backward = max(abs(backward_residual(bridge_potential, x, t)) for x, t in test_points)
201
- hjb = max(abs(hjb_residual(value, x, t)) for x, t in test_points)
202
- control_gap = max(
203
- abs(optimal_control(value, x, t)
204
- - float(bridge_drift(torch.tensor(x, dtype=DTYPE), torch.tensor(t, dtype=DTYPE))))
205
- for x, t in test_points)
206
- if loud:
207
- print(f"\nThe pinned potential h(z, t) and its two equations")
208
- print(f" largest backward residual d_t h + (eps/2) h'' = {backward:.2e}")
209
- print(f" largest Hamilton-Jacobi-Bellman residual = {hjb:.2e}")
210
- print(f" largest gap between -grad V and (y - z) / (1 - t) = {control_gap:.2e}")
211
-
212
- mean_half, variance_half = bridge_law(0.5)
213
- sample = simulate_bridge(steps, PATHS, EPSILON, generator)
214
- half = sample[steps // 2] # The state at t = 1/2 on the grid.
215
- state = 0.6732 # The particular state Section 7.18 evaluates the drift at.
216
- drift_here = float(bridge_drift(torch.tensor(state, dtype=DTYPE),
217
- torch.tensor(0.25, dtype=DTYPE)))
218
- mean_quarter, variance_quarter = bridge_law(0.25)
219
- spreads = {e: math.sqrt(e * 0.25) for e in (0.25, 1.0, 4.0)} # Standard deviations at t = 1/2.
220
- if loud:
221
- print(f"\nThe bridge from {START:g} to {END:g} at epsilon = {EPSILON:g}")
222
- print(f" at t = 0.25: mean {mean_quarter:.4f}, variance {variance_quarter:.4f}, "
223
- f"standard deviation {math.sqrt(variance_quarter):.4f}")
224
- print(f" at X_0.25 = {state}: drift = ({END:g} - {state}) / 0.75 = {drift_here:.4f}, "
225
- f"against the straight-line velocity {END - START:.0f}")
226
- print(f" at t = 1/2: mean {mean_half:.4f}, variance {variance_half:.4f}")
227
- print(f" simulated mean {float(half.mean()):.4f}, variance {float(half.var()):.4f}")
228
- print(f" standard deviation at t = 1/2 for eps = 0.25, 1, 4: "
229
- f"{[round(s, 2) for s in spreads.values()]}")
230
-
231
- report = {
232
- "KL from the control energy": closed_form,
233
- "KL from the two endpoint laws": endpoint_form,
234
- "KL from the simulated paths": girsanov,
235
- "simulated endpoint mean": float(endpoints.mean()),
236
- "backward equation residual": backward,
237
- "Hamilton-Jacobi-Bellman residual": hjb,
238
- "-grad V against the bridge drift": control_gap,
239
- "bridge mean at t = 0.25": mean_quarter,
240
- "bridge variance at t = 0.25": variance_quarter,
241
- "bridge drift at X = 0.6732": drift_here,
242
- "straight-line velocity": float(END - START),
243
- "bridge mean at t = 1/2": mean_half,
244
- "bridge variance at t = 1/2": variance_half,
245
- "simulated mean at t = 1/2": float(half.mean()),
246
- "simulated variance at t = 1/2": float(half.var()),
247
- "simulated endpoint, mean": float(sample[-1].mean()),
248
- "spread at t = 1/2, eps = 0.25": spreads[0.25],
249
- "spread at t = 1/2, eps = 1": spreads[1.0],
250
- "spread at t = 1/2, eps = 4": spreads[4.0],
251
- }
252
- print_report("A diffusion reference, Chapter 7 Sections 7.16 to 7.18", report)
253
- save_run(args.out, config=vars(args), report=report) # Written only with --out.
254
-
255
-
256
- if __name__ == "__main__":
257
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_7/optimal_transport.py DELETED
@@ -1,323 +0,0 @@
1
- """Optimal transport, from a pairing of four points to the Gaussian closed form.
2
-
3
- Two distributions fix how often each point appears and leave the assignment between them free. This
4
- file prices every assignment with the squared cost, writes a pairing as a matrix, relaxes Monge's
5
- map to Kantorovich's coupling, certifies the optimum with prices, and ends with the two Gaussians
6
- whose optimal map is an affine function.
7
-
8
- Chapter 7, Sections 7.1 to 7.6 of the notes. Runs on the chapter's own points, with no training and
9
- no data. Prints the cost of each pairing, the plan and the prices of the two-by-two example, the
10
- sorted pairing on three points, and the transport of N(0,1) to N(3,4).
11
-
12
- python lecture_7/optimal_transport.py # every worked number of Sections 7.1 to 7.6
13
- python lecture_7/optimal_transport.py --steps 20 # a coarse quantile grid, for a first look
14
- """
15
-
16
- # %% 01. Imports and configuration
17
- from __future__ import annotations
18
-
19
- import itertools
20
- import math
21
-
22
- import torch
23
-
24
- from cis6270.runner import common_parser, print_report, save_run, set_seed
25
-
26
- DTYPE = torch.float64 # The chapter quotes six figures, so every number here is double precision.
27
-
28
-
29
- # %% 02. The cost of moving one unit of mass
30
- #
31
- # The price of carrying one unit from x to y is the squared Euclidean distance, under which a move
32
- # twice as long costs four times as much. Section 7.1.1.
33
-
34
-
35
- def cost_matrix(x: torch.Tensor, y: torch.Tensor, p: float = 2.0) -> torch.Tensor:
36
- """C_ij = |x_i - y_j|^p on the line. x: [n], y: [m] -> [n, m]."""
37
- return (x[:, None] - y[None, :]).abs() ** p # [n, m] one price per route.
38
-
39
-
40
- def plan_cost(C: torch.Tensor, pi: torch.Tensor) -> float:
41
- """The Frobenius inner product <C, pi>, the mass on each route times the price of that route."""
42
- return float((C * pi).sum())
43
-
44
-
45
- # %% 03. Four points and two pairings
46
- #
47
- # Sources at 0 and 2, targets at 1 and 3, each point with mass 1/2. Section 7.1.2 counts the two
48
- # arrows of a pairing without weighting them by mass, and Section 7.1.3 weights them.
49
-
50
-
51
- def two_pairings() -> dict:
52
- """The ordered and crossed pairings of the four points, by arrow count and by mass."""
53
- x = torch.tensor([0.0, 2.0], dtype=DTYPE) # Source points.
54
- y = torch.tensor([1.0, 3.0], dtype=DTYPE) # Target points.
55
- C = cost_matrix(x, y) # [[1, 9], [1, 1]] the four route prices.
56
- ordered = torch.tensor([[0.5, 0.0], [0.0, 0.5]], dtype=DTYPE) # 0 -> 1 and 2 -> 3.
57
- crossed = torch.tensor([[0.0, 0.5], [0.5, 0.0]], dtype=DTYPE) # 0 -> 3 and 2 -> 1.
58
- independent = torch.full((2, 2), 0.25, dtype=DTYPE) # a_i b_j with a = b = (1/2, 1/2).
59
- return {
60
- "C": C,
61
- "arrows_ordered": float(C[0, 0] + C[1, 1]), # 1 + 1, the two arrow lengths squared.
62
- "arrows_crossed": float(C[0, 1] + C[1, 0]), # 9 + 1.
63
- "mass_ordered": plan_cost(C, ordered), # Each arrow weighted by the 1/2 it carries.
64
- "mass_crossed": plan_cost(C, crossed),
65
- "mass_independent": plan_cost(C, independent), # 1/4 on every route.
66
- }
67
-
68
-
69
- # %% 04. A pairing written as a matrix
70
- #
71
- # A coupling is an n by m matrix with row sums a and column sums b, so pi 1_m = a and pi^T 1_n = b.
72
- # The independent coupling a_i b_j always lies in that set. Section 7.1.3.
73
-
74
-
75
- def is_coupling(pi: torch.Tensor, a: torch.Tensor, b: torch.Tensor, tol: float = 1e-9) -> bool:
76
- """True when the row sums equal a, the column sums equal b, and no entry is negative."""
77
- return bool(
78
- (pi >= -tol).all()
79
- and (pi.sum(dim=1) - a).abs().max() < tol # pi 1_m = a.
80
- and (pi.sum(dim=0) - b).abs().max() < tol # pi^T 1_n = b.
81
- )
82
-
83
-
84
- # %% 05. Monge's formulation, and the source that has to be split
85
- #
86
- # A map sends each source point to one destination, so for n equal masses it is a permutation and
87
- # the minimization runs over n! of them. A point mass pushes forward to a point mass, which is why
88
- # no map carries delta_0 to a law with mass at two places. Sections 7.2 and 7.2.1.
89
-
90
-
91
- def monge_permutations(x: torch.Tensor, y: torch.Tensor) -> list[tuple[float, tuple[int, ...]]]:
92
- """Every permutation of n equal point masses and its average cost, cheapest first."""
93
- n = len(x)
94
- C = cost_matrix(x, y) # [n, n].
95
- costs = [(float(sum(C[i, p[i]] for i in range(n)) / n), p)
96
- for p in itertools.permutations(range(n))] # Mass 1/n on each of the n routes.
97
- return sorted(costs)
98
-
99
-
100
- def pushforward_of_a_point_mass(destination: float) -> dict[float, float]:
101
- """T_# delta_0 is the point mass at T(0), whatever the map T does elsewhere."""
102
- return {destination: 1.0} # One destination, all of the mass, so nu is out of reach.
103
-
104
-
105
- NU_ATOMS = {1.0: 0.5, 2.0: 0.5} # nu = (1/2) delta_1 + (1/2) delta_2 of Section 7.2.1.
106
-
107
-
108
- # %% 06. Kantorovich's relaxation, solved by hand on the two-by-two
109
- #
110
- # The feasible set of the chapter's example is the one-parameter family pi(r), and the cost along it
111
- # is 5.8 - 8r, decreasing, so the optimum sits at the largest feasible r. Sections 7.3 to 7.3.2.
112
-
113
- SOURCE_X = torch.tensor([0.0, 2.0], dtype=DTYPE) # Source points of the running example.
114
- SOURCE_A = torch.tensor([0.6, 0.4], dtype=DTYPE) # Their masses.
115
- TARGET_Y = torch.tensor([1.0, 3.0], dtype=DTYPE) # Target points.
116
- TARGET_B = torch.tensor([0.3, 0.7], dtype=DTYPE) # Their required masses.
117
-
118
-
119
- def feasible_plan(r: float) -> torch.Tensor:
120
- """The coupling with mass r from 0 to 1, the one degree of freedom the margins leave."""
121
- return torch.tensor([[r, 0.6 - r], [0.3 - r, 0.1 + r]], dtype=DTYPE) # [2, 2].
122
-
123
-
124
- def feasible_interval() -> tuple[float, float]:
125
- """Nonnegativity of the four entries of pi(r) bounds r, and the binding pair is 0 <= r <= 0.3."""
126
- lower = max(0.0, -0.1) # From r >= 0 and 0.1 + r >= 0.
127
- upper = min(0.6, 0.3) # From 0.6 - r >= 0 and 0.3 - r >= 0.
128
- return lower, upper
129
-
130
-
131
- def cost_along_the_segment(r: float, C: torch.Tensor) -> float:
132
- """<C, pi(r)> as a function of r alone, which collects into 5.8 - 8r."""
133
- return plan_cost(C, feasible_plan(r)) # Linear in r with slope C11 - C12 - C21 + C22 = -8.
134
-
135
-
136
- # %% 07. Prices and the dual
137
- #
138
- # A price f_i at each source point and g_j at each target point is feasible when f_i + g_j <= C_ij on
139
- # every route. Complementary slackness fixes the prices from the loaded routes of an optimal plan,
140
- # one substitution at a time, after one price is pinned to remove the shift. Sections 7.4 to 7.4.4.
141
-
142
-
143
- def prices_from_loaded_routes(C: torch.Tensor, pi: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
144
- """Solve f_i + g_j = C_ij on the routes carrying mass, with f_1 = 0 fixing the shift."""
145
- n, m = C.shape
146
- f = torch.full((n,), float("nan"), dtype=DTYPE)
147
- g = torch.full((m,), float("nan"), dtype=DTYPE)
148
- f[0] = 0.0 # Adding a constant to f and subtracting it from g changes no sum f_i + g_j.
149
- for _ in range(n + m): # Each pass propagates along one more loaded route.
150
- for i in range(n):
151
- for j in range(m):
152
- if pi[i, j] <= 0:
153
- continue # An empty route imposes no equation.
154
- if not torch.isnan(f[i]) and torch.isnan(g[j]):
155
- g[j] = C[i, j] - f[i] # The route prices the target point.
156
- elif torch.isnan(f[i]) and not torch.isnan(g[j]):
157
- f[i] = C[i, j] - g[j] # The route prices the source point.
158
- return f, g
159
-
160
-
161
- def slack(C: torch.Tensor, f: torch.Tensor, g: torch.Tensor) -> torch.Tensor:
162
- """The matrix C_ij - f_i - g_j, nonnegative exactly when the prices are feasible."""
163
- return C - f[:, None] - g[None, :] # [n, m].
164
-
165
-
166
- def dual_value(a: torch.Tensor, f: torch.Tensor, b: torch.Tensor, g: torch.Tensor) -> float:
167
- """<a, f> + <b, g>, the value of a pair of prices."""
168
- return float(a @ f + b @ g)
169
-
170
-
171
- # %% 08. Powers of a distance, and non-crossing on the line
172
- #
173
- # With the squared cost the difference between the ordered and crossed pairings is -2 times the
174
- # product of the two gaps, so an optimal plan on the line contains no crossing. The quantile form
175
- # then computes W_p by sorting. Sections 7.5 and 7.5.1.
176
-
177
-
178
- def crossing_difference(x1: float, x2: float, y1: float, y2: float) -> tuple[float, float]:
179
- """The ordered cost minus the crossed cost, expanded and in the collected form -2 dx dy."""
180
- expanded = (x1 - y1) ** 2 + (x2 - y2) ** 2 - (x1 - y2) ** 2 - (x2 - y1) ** 2
181
- collected = -2.0 * (x2 - x1) * (y2 - y1) # Every squared term cancels, leaving the cross terms.
182
- return expanded, collected
183
-
184
-
185
- def quantile_levels(levels: int) -> torch.Tensor:
186
- """Midpoints (i + 1/2) / levels of a uniform grid on [0, 1], the levels q the integral uses."""
187
- return (torch.arange(levels, dtype=DTYPE) + 0.5) / levels # [levels].
188
-
189
-
190
- def empirical_quantile(points: torch.Tensor, q: torch.Tensor) -> torch.Tensor:
191
- """F^{-1}(q) = inf{x : F(x) >= q} for n equally weighted points, a step function of q."""
192
- ordered = points.sort().values # [n] the sorted points, which are the steps of F^{-1}.
193
- index = torch.clamp((q * len(points)).floor().long(), max=len(points) - 1) # [levels].
194
- return ordered[index] # [levels] the quantile at each level.
195
-
196
-
197
- def wasserstein_on_the_line(x: torch.Tensor, y: torch.Tensor, p: float, levels: int) -> float:
198
- """W_p^p as the integral over quantile levels q of |F_mu^{-1}(q) - F_nu^{-1}(q)|^p.
199
-
200
- With n equally weighted points the quantile function is a step function whose values are the
201
- sorted points, so the level-by-level pairing is the sorted pairing and one sort on each side
202
- computes the whole integral.
203
- """
204
- q = quantile_levels(levels) # [levels].
205
- difference = empirical_quantile(x, q) - empirical_quantile(y, q) # [levels].
206
- return float((difference.abs() ** p).mean()) # The integral of a step function over [0, 1].
207
-
208
-
209
- # %% 09. Gaussians, and the case where the plan is a map
210
- #
211
- # For two Gaussians on the line the squared 2-Wasserstein distance is the squared difference of the
212
- # means plus the squared difference of the standard deviations, and the optimal map is the increasing
213
- # affine function matching quantiles. Sections 7.6 to 7.6.2.
214
-
215
-
216
- def gaussian_w2_squared(m0: float, s0: float, m1: float, s1: float) -> float:
217
- """W_2^2 between N(m0, s0^2) and N(m1, s1^2)."""
218
- return (m0 - m1) ** 2 + (s0 - s1) ** 2
219
-
220
-
221
- def brenier_map(x: torch.Tensor, m0: float, s0: float, m1: float, s1: float) -> torch.Tensor:
222
- """T(x) = m1 + (s1 / s0) (x - m0), the gradient of a convex function in one dimension."""
223
- return m1 + (s1 / s0) * (x - m0) # Same shape as x.
224
-
225
-
226
- # %% 10. The report
227
- def main() -> None:
228
- parser = common_parser(__doc__.split("\n")[0])
229
- parser.set_defaults(steps=3072) # --steps is the number of quantile levels in Section 7.5.1.
230
- args = parser.parse_args()
231
- set_seed(args.seed)
232
- loud = not args.quiet
233
-
234
- pairings = two_pairings()
235
- if loud:
236
- print("Four points: sources at 0 and 2, targets at 1 and 3, mass 1/2 each")
237
- print(f" arrows ordered {pairings['arrows_ordered']:.0f} "
238
- f"crossed {pairings['arrows_crossed']:.0f}")
239
- print(f" by mass ordered {pairings['mass_ordered']:.0f} "
240
- f"crossed {pairings['mass_crossed']:.0f} "
241
- f"independent {pairings['mass_independent']:.0f}")
242
-
243
- C = cost_matrix(SOURCE_X, TARGET_Y) # [[1, 9], [1, 1]] for the running example.
244
- low, high = feasible_interval()
245
- grid = torch.linspace(low, high, 7, dtype=DTYPE) # Seven points along the feasible segment.
246
- best_r = float(grid[int(torch.tensor([cost_along_the_segment(float(r), C)
247
- for r in grid]).argmin())])
248
- pi_star = feasible_plan(high) # The cost decreases in r, so the optimum is the right endpoint.
249
- cost_star = plan_cost(C, pi_star)
250
- if loud:
251
- print(f"\nTwo-by-two: a = {SOURCE_A.tolist()}, b = {TARGET_B.tolist()}, C = {C.tolist()}")
252
- print(f" feasible r in [{low:.1f}, {high:.1f}], cost(r) = "
253
- f"{cost_along_the_segment(0.0, C):.1f} - "
254
- f"{(cost_along_the_segment(0.0, C) - cost_along_the_segment(1.0, C)):.0f} r")
255
- print(f" pi* = {pi_star.tolist()} <C, pi*> = {cost_star:.1f}")
256
-
257
- f, g = prices_from_loaded_routes(C, pi_star)
258
- S = slack(C, f, g)
259
- dual = dual_value(SOURCE_A, f, TARGET_B, g)
260
- gap = cost_star - dual # Equals sum_ij pi_ij (C_ij - f_i - g_j), term by term nonnegative.
261
- if loud:
262
- print(f"\nPrices f = {f.tolist()}, g = {g.tolist()}")
263
- print(f" slack = {S.tolist()} dual value {dual:.1f} duality gap {gap:.1e}")
264
- print(f" slackness: mass on a route of positive slack = "
265
- f"{float((pi_star * (S > 1e-12)).sum()):.1f}")
266
-
267
- expanded, collected = crossing_difference(0.0, 2.0, 1.0, 3.0)
268
- three_x = torch.tensor([4.0, 1.0, 6.0], dtype=DTYPE) # Section 7.5.2.
269
- three_y = torch.tensor([9.0, 2.0, 3.0], dtype=DTYPE)
270
- permutations = monge_permutations(three_x.sort().values, three_y.sort().values)
271
- quantile_w22 = wasserstein_on_the_line(three_x, three_y, 2.0, args.steps)
272
- C3 = cost_matrix(three_x.sort().values, three_y.sort().values)
273
- f3 = torch.tensor([0.0, 0.0, 8.0], dtype=DTYPE) # Three equations in six unknowns, one solution.
274
- g3 = torch.tensor([1.0, 1.0, 1.0], dtype=DTYPE)
275
- dual3 = dual_value(torch.full((3,), 1 / 3, dtype=DTYPE), f3,
276
- torch.full((3,), 1 / 3, dtype=DTYPE), g3)
277
- if loud:
278
- print(f"\nNo crossing: expanded {expanded:+.0f} = collected {collected:+.0f}")
279
- print(f" three points, six permutations: {[round(c, 3) for c, _ in permutations]}")
280
- print(f" quantile integral on {args.steps} levels: {quantile_w22:.4f}")
281
- print(f" slack of f = (0, 0, 8) and g = (1, 1, 1): {slack(C3, f3, g3).tolist()}")
282
- print(f" dual value {dual3:.4f}")
283
-
284
- source = torch.special.ndtri(quantile_levels(args.steps)) # N(0,1) read at the same levels.
285
- mapped = brenier_map(source, 0.0, 1.0, 3.0, 2.0) # T(x) = 3 + 2x.
286
- displacement_square = float(((mapped - source) ** 2).mean()) # E[(3 + X)^2] = 9 + 1.
287
- w22_gauss = gaussian_w2_squared(0.0, 1.0, 3.0, 2.0)
288
- if loud:
289
- probe = brenier_map(torch.tensor([-1.0, 0.0, 1.0], dtype=DTYPE), 0.0, 1.0, 3.0, 2.0)
290
- print(f"\nN(0,1) to N(3,4): T(x) = {float(probe[1]):.0f} + "
291
- f"{float(probe[2] - probe[1]):.0f}x, T(-1) = {float(probe[0]):.0f}, "
292
- f"T(0) = {float(probe[1]):.0f}, T(1) = {float(probe[2]):.0f}")
293
- print(f" sampled mean square displacement {displacement_square:.3f}")
294
-
295
- report = {
296
- "ordered pairing, by arrows": pairings["arrows_ordered"],
297
- "crossed pairing, by arrows": pairings["arrows_crossed"],
298
- "ordered pairing, by mass": pairings["mass_ordered"],
299
- "crossed pairing, by mass": pairings["mass_crossed"],
300
- "independent coupling, by mass": pairings["mass_independent"],
301
- "atoms of T_# delta_0, of nu": f"{len(pushforward_of_a_point_mass(1.0))}, {len(NU_ATOMS)}",
302
- "permutations at n = 2, 3, 256": f"2, 6, a number with "
303
- f"{len(str(math.factorial(256)))} digits",
304
- "argmin of the cost over r": best_r,
305
- "<C, pi*>": cost_star,
306
- "dual value <a,f> + <b,g>": dual,
307
- "duality gap": gap,
308
- "pi* is a coupling": is_coupling(pi_star, SOURCE_A, TARGET_B),
309
- "W_2 of the two-by-two": cost_star ** 0.5,
310
- "sorted pairing on three points": permutations[0][0],
311
- "worst pairing on three points": permutations[-1][0],
312
- "W_2^2 from the quantile integral": quantile_w22,
313
- "dual value on three points": dual3,
314
- "W_2^2 of N(0,1) and N(3,4)": float(w22_gauss),
315
- "W_2 of N(0,1) and N(3,4)": float(w22_gauss) ** 0.5,
316
- "E[(T(X) - X)^2] on a sample": displacement_square,
317
- }
318
- print_report("Optimal transport, Chapter 7 Sections 7.1 to 7.6", report)
319
- save_run(args.out, config=vars(args), report=report) # Written only with --out.
320
-
321
-
322
- if __name__ == "__main__":
323
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_7/ot_flow_matching.py DELETED
@@ -1,215 +0,0 @@
1
- """Optimal transport inside flow matching: the same loss, trained on a different coupling.
2
-
3
- The flow matching loss of Chapter 2 is defined for any joint law whose two marginals are p_0 and
4
- p_1, and the independent draw of a source sample and a data sample is one such law. Replacing it
5
- with the optimal coupling of the batch changes the training pairs and leaves the loss, the network
6
- and the sampler as they are. Under the optimal coupling no two paths cross, so the marginal
7
- velocity at a point is the velocity of the single path through it and the trajectories come out
8
- straight.
9
-
10
- Chapter 7, Sections 7.8 and 7.8.1 of the notes. Trains two velocity networks on the four-Gaussian
11
- target of `cis6270.data`, one on the independent coupling and one on the exact coupling of each
12
- minibatch. Prints the coupling cost, the straightness of the learned paths and the one-step error.
13
-
14
- python lecture_7/ot_flow_matching.py # 300 steps per model, under a minute
15
- python lecture_7/ot_flow_matching.py --steps 20 # a few seconds, for a first look
16
- """
17
-
18
- # %% 01. Imports and configuration
19
- from __future__ import annotations
20
-
21
- import torch
22
-
23
- from cis6270.data import gaussian_mixture_2d
24
- from cis6270.metrics import mode_report
25
- from cis6270.nets import VelocityMLP, count_parameters
26
- from cis6270.runner import (
27
- common_parser, pick_device, print_report, save_run, set_seed, train,
28
- )
29
-
30
-
31
- # %% 02. The coupling of a batch
32
- #
33
- # A batch holds n source samples and m data samples with uniform masses 1/n and 1/m, and the
34
- # transport problem on that batch has nm unknowns against n + m equality constraints. With n = m the
35
- # vertex solution is a permutation, which the Hungarian algorithm returns in order n^3 operations.
36
- # Section 7.8.1.
37
-
38
-
39
- def batch_cost_matrix(x0: torch.Tensor, x1: torch.Tensor) -> torch.Tensor:
40
- """C_ij = ||x0_i - x1_j||^2 between every source sample and every data sample. [n, m]."""
41
- return torch.cdist(x0, x1) ** 2 # [n, m] squared Euclidean cost.
42
-
43
-
44
- def optimal_pairing(x0: torch.Tensor, x1: torch.Tensor) -> torch.Tensor:
45
- """The permutation of the data samples that minimizes the total squared cost of the batch."""
46
- from scipy.optimize import linear_sum_assignment # The assignment problem of Section 7.2.
47
-
48
- C = batch_cost_matrix(x0, x1).detach().cpu().numpy() # [n, n].
49
- _, columns = linear_sum_assignment(C) # The cheapest permutation, in order n^3 operations.
50
- return torch.as_tensor(columns, device=x0.device, dtype=torch.long) # [n].
51
-
52
-
53
- def couple(x0: torch.Tensor, x1: torch.Tensor, kind: str) -> torch.Tensor:
54
- """Reorder the data samples so that row i of the batch is the pair the coupling assigns to x0_i."""
55
- if kind == "independent":
56
- return x1 # Two independent draws, which is the coupling of Chapters 2 through 6.
57
- return x1[optimal_pairing(x0, x1)] # The optimal coupling of this batch.
58
-
59
-
60
- def vertex_count(n: int, m: int) -> dict[str, int]:
61
- """The size of the batch problem: nm unknowns, n + m constraints, at most n + m - 1 loaded."""
62
- return {"unknowns": n * m, "constraints": n + m, "loaded_routes": n + m - 1}
63
-
64
-
65
- # %% 03. The loss, with pi the only symbol that changes
66
- #
67
- # The network still predicts a velocity, the target is still the straight-line displacement, and the
68
- # time is still uniform on [0, 1]. Section 7.8.
69
-
70
-
71
- def flow_matching_loss(model: VelocityMLP, x0: torch.Tensor, x1: torch.Tensor) -> torch.Tensor:
72
- """E_t ||v_theta((1-t) x_0 + t x_1) - (x_1 - x_0)||^2 over the pairs the coupling supplies."""
73
- t = torch.rand(x0.shape[0], device=x0.device) # [B] one time per pair.
74
- xt = (1.0 - t[:, None]) * x0 + t[:, None] * x1 # [B, 2] the point on the straight path.
75
- ut = x1 - x0 # [B, 2] the conditional velocity, constant along the path.
76
- return ((model(xt, t) - ut) ** 2).sum(dim=1).mean() # Sum coordinates, average pairs.
77
-
78
-
79
- def conditional_velocity_energy(x0: torch.Tensor, x1: torch.Tensor) -> float:
80
- """The average squared conditional velocity, which Proposition 7.5 makes the kinetic action."""
81
- return float(((x1 - x0) ** 2).sum(dim=1).mean())
82
-
83
-
84
- # %% 04. Sampling, and two measurements of the learned field
85
- #
86
- # A straight trajectory is integrated exactly by one Euler step, so the gap between a one-step
87
- # sample and a many-step sample measures how far the learned paths are from straight. The second
88
- # measurement compares the velocity along a trajectory with the net displacement of that
89
- # trajectory. Section 7.8.
90
-
91
-
92
- @torch.no_grad()
93
- def integrate(model: VelocityMLP, x0: torch.Tensor, steps: int) -> tuple[torch.Tensor, float]:
94
- """Euler integration from t = 0 to t = 1, returning the samples and the path straightness.
95
-
96
- The straightness is the average of ||v_t(x_t) - (x_1 - x_0)||^2 along the path, which is zero
97
- exactly when the velocity never changes along a trajectory.
98
- """
99
- x = x0.clone() # [B, 2].
100
- dt = 1.0 / steps
101
- velocities = []
102
- for step in range(steps):
103
- t = torch.full((x.shape[0],), step * dt, device=x.device) # [B] shared time.
104
- v = model(x, t) # [B, 2] the learned velocity at the current state.
105
- velocities.append(v)
106
- x = x + dt * v # One Euler step.
107
- net = (x - x0)[None, :, :] # [1, B, 2] the net displacement of each trajectory.
108
- deviation = ((torch.stack(velocities) - net) ** 2).sum(dim=2).mean() # Scalar.
109
- return x, float(deviation)
110
-
111
-
112
- @torch.no_grad()
113
- def one_step_error(model: VelocityMLP, x0: torch.Tensor, steps: int) -> float:
114
- """The distance between the one-step sample x_0 + v_0(x_0) and the many-step sample."""
115
- fine, _ = integrate(model, x0, steps) # [B, 2] the reference trajectory endpoint.
116
- t0 = torch.zeros(x0.shape[0], device=x0.device)
117
- coarse = x0 + model(x0, t0) # [B, 2] a single Euler step of size 1.
118
- return float((coarse - fine).norm(dim=1).mean())
119
-
120
-
121
- # %% 05. Training one model
122
- def fit(kind: str, args, device: torch.device, data: torch.Tensor) -> dict:
123
- """Train a velocity network on the coupling named by ``kind`` and measure what it learned."""
124
- set_seed(args.seed) # The same initialization and the same batches for both couplings.
125
- model = VelocityMLP(dim=2, width=args.width).to(device)
126
- costs: list[float] = []
127
-
128
- def loss_fn(step: int) -> torch.Tensor:
129
- x1 = data[torch.randint(0, len(data), (args.batch_size,), device=device)] # [B, 2].
130
- x0 = torch.randn_like(x1) # [B, 2] the source sample.
131
- paired = couple(x0, x1, kind) # [B, 2] reordered by the coupling.
132
- costs.append(conditional_velocity_energy(x0, paired))
133
- return flow_matching_loss(model, x0, paired)
134
-
135
- losses = train(model, loss_fn, steps=args.steps, lr=args.lr, quiet=True)
136
- torch.manual_seed(args.seed + 1) # The same starting noise for both samplers.
137
- x0 = torch.randn(args.samples, 2, device=device)
138
- samples, straightness = integrate(model, x0, args.sample_steps)
139
- report = mode_report(samples.cpu())
140
- return {
141
- "kind": kind,
142
- "loss": sum(losses[-50:]) / len(losses[-50:]),
143
- "coupling_cost": sum(costs) / len(costs),
144
- "straightness": straightness,
145
- "one_step_error": one_step_error(model, x0, args.sample_steps),
146
- "modes_found": report["modes_found"],
147
- "nearest_distance": report["nearest_distance"],
148
- }
149
-
150
-
151
- # %% 06. The report
152
- def main() -> None:
153
- parser = common_parser(__doc__.split("\n")[0])
154
- parser.set_defaults(steps=300, batch_size=128, width=64, samples=512, sample_steps=50)
155
- args = parser.parse_args()
156
- set_seed(args.seed)
157
- device = pick_device(args.device)
158
- loud = not args.quiet
159
- data = gaussian_mixture_2d(num_points=4096, seed=args.seed).to(device) # [4096, 2].
160
-
161
- # The two-point batch of Section 7.8.1, where both couplings can be written out.
162
- x = torch.tensor([[0.0], [2.0]]) # Two source samples on the line.
163
- y = torch.tensor([[1.0], [3.0]]) # Two data samples.
164
- C = batch_cost_matrix(x, y) # [[1, 9], [1, 1]].
165
- independent_energy = float(C.mean()) # Weight 1/4 on each of the four pairs.
166
- optimal_energy = float(C[torch.arange(2), optimal_pairing(x, y)].mean()) # Weight 1/2 each.
167
- sizes = vertex_count(256, 256) # The counting the chapter does, at n = m = 256.
168
- if loud:
169
- print("Two source samples at 0 and 2, two data samples at 1 and 3")
170
- print(f" independent coupling, average squared velocity {independent_energy:.0f}")
171
- print(f" optimal coupling, average squared velocity {optimal_energy:.0f}")
172
- print(f"\nA batch of 256 and 256: {sizes['unknowns']:,} unknowns, "
173
- f"{sizes['constraints']} constraints, at most {sizes['loaded_routes']} loaded routes")
174
- print(f" an exact solve costs about {256 ** 3:.3g} operations, repeated every batch")
175
- print(f" training batch {args.batch_size}, device={device}, parameters="
176
- f"{count_parameters(VelocityMLP(dim=2, width=args.width)):,}")
177
-
178
- results = {}
179
- for kind in ("independent", "optimal"):
180
- if loud:
181
- print(f"\ntraining on the {kind} coupling")
182
- results[kind] = fit(kind, args, device, data)
183
- if loud:
184
- r = results[kind]
185
- print(f" loss {r['loss']:.4f} batch coupling cost {r['coupling_cost']:.3f}")
186
- print(f" straightness {r['straightness']:.4f} "
187
- f"one-step error {r['one_step_error']:.4f}")
188
-
189
- ratio = results["independent"]["coupling_cost"] / max(results["optimal"]["coupling_cost"], 1e-12)
190
- report = {
191
- "two-point batch, independent": independent_energy,
192
- "two-point batch, optimal": optimal_energy,
193
- "batch plan unknowns": sizes["unknowns"],
194
- "batch plan constraints": sizes["constraints"],
195
- "loaded routes at a vertex": sizes["loaded_routes"],
196
- "coupling cost, independent": results["independent"]["coupling_cost"],
197
- "coupling cost, optimal": results["optimal"]["coupling_cost"],
198
- "cost ratio": ratio,
199
- "training loss, independent": results["independent"]["loss"],
200
- "training loss, optimal": results["optimal"]["loss"],
201
- "straightness, independent": results["independent"]["straightness"],
202
- "straightness, optimal": results["optimal"]["straightness"],
203
- "one-step error, independent": results["independent"]["one_step_error"],
204
- "one-step error, optimal": results["optimal"]["one_step_error"],
205
- "modes found, independent": results["independent"]["modes_found"],
206
- "modes found, optimal": results["optimal"]["modes_found"],
207
- "nearest mode, independent": results["independent"]["nearest_distance"],
208
- "nearest mode, optimal": results["optimal"]["nearest_distance"],
209
- }
210
- print_report("Optimal transport inside flow matching, Chapter 7 Section 7.8", report)
211
- save_run(args.out, config=vars(args), report=report) # Written only with --out.
212
-
213
-
214
- if __name__ == "__main__":
215
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_7/sf2m.py DELETED
@@ -1,248 +0,0 @@
1
- """Simulation-Free Score and Flow Matching: two closed-form targets at one bridge sample.
2
-
3
- With the entropic plan computed on a minibatch, the bridge is a mixture of Brownian bridges between
4
- pairs drawn from that plan, and a Brownian bridge at a time t is Gaussian. One normal draw gives a
5
- sample, and at that sample both the bridge drift and the score of the bridge marginal are available
6
- without simulating a path. Subtracting half the noise variance times the score turns the drift into
7
- the velocity of the probability flow, whose dependence on epsilon cancels.
8
-
9
- Chapter 7, Section 7.19.3 of the notes. Runs on the endpoint pair x_0 = 0, x_1 = 2 at epsilon = 1,
10
- and on the two-point batch of Section 7.8.1. Prints the two regression targets at t = 0.25 and at
11
- t = 0.75, the scaled score at every time on the grid, and the entropic plan of the batch.
12
-
13
- python lecture_7/sf2m.py # 101 times on the grid
14
- python lecture_7/sf2m.py --steps 20 # a coarse time grid, for a first look
15
- """
16
-
17
- # %% 01. Imports and configuration
18
- from __future__ import annotations
19
-
20
- import torch
21
-
22
- from cis6270.runner import common_parser, print_report, save_run, set_seed
23
-
24
- DTYPE = torch.float64 # The chapter quotes four figures of both regression targets.
25
-
26
- X0, X1 = 0.0, 2.0 # The endpoint pair the chapter works through.
27
- EPSILON = 1.0 # The reference noise variance per unit time.
28
- DRAW = 0.4 # The normal draw z the chapter uses at both times.
29
-
30
-
31
- # %% 02. One sample from the bridge
32
- #
33
- # The Brownian bridge at time t is Gaussian with mean the straight interpolation and variance
34
- # epsilon t (1 - t), so a sample costs one normal draw and no intermediate step.
35
- # Section 7.19.3.
36
-
37
-
38
- def bridge_mean(x0: torch.Tensor, x1: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
39
- """m_t = (1 - t) x_0 + t x_1, the mean of the bridge between the two ends."""
40
- return (1.0 - t) * x0 + t * x1
41
-
42
-
43
- def bridge_std(t: torch.Tensor, epsilon: float = EPSILON) -> torch.Tensor:
44
- """sigma_t = sqrt(epsilon t (1 - t)), which vanishes at both ends and peaks at t = 1/2."""
45
- return torch.sqrt(epsilon * t * (1.0 - t))
46
-
47
-
48
- def bridge_sample(x0: torch.Tensor, x1: torch.Tensor, t: torch.Tensor, z: torch.Tensor,
49
- epsilon: float = EPSILON) -> torch.Tensor:
50
- """x = m_t + sigma_t z, one draw from the bridge at time t."""
51
- return bridge_mean(x0, x1, t) + bridge_std(t, epsilon) * z
52
-
53
-
54
- # %% 03. The two conditional quantities
55
- #
56
- # Both are conditional on the drawn pair, and neither depends on a quantity a network has to
57
- # estimate first. Section 7.19.3.
58
-
59
-
60
- def conditional_drift(x1: torch.Tensor, x: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
61
- """u_t(x | x_0, x_1) = (x_1 - x) / (1 - t), the remaining displacement over the remaining time."""
62
- return (x1 - x) / (1.0 - t)
63
-
64
-
65
- def conditional_score(x0: torch.Tensor, x1: torch.Tensor, x: torch.Tensor, t: torch.Tensor,
66
- epsilon: float = EPSILON) -> torch.Tensor:
67
- """grad log p_t(x | x_0, x_1) = (m_t - x) / (epsilon t (1 - t)), displacement over variance."""
68
- return (bridge_mean(x0, x1, t) - x) / (epsilon * t * (1.0 - t))
69
-
70
-
71
- # %% 04. From the bridge drift to a probability flow velocity
72
- #
73
- # Subtracting half the noise variance times the score converts a diffusion drift into the velocity
74
- # of the probability flow, which has the same marginals with no noise at all. The second form shows
75
- # the straight-line velocity plus a coefficient times the displacement of the sample from the mean,
76
- # with the dependence on epsilon cancelled. Section 7.19.3.
77
-
78
-
79
- def flow_coefficient(t: torch.Tensor) -> torch.Tensor:
80
- """(1 - 2t) / (2 t (1 - t)), positive before t = 1/2 and negative after."""
81
- return (1.0 - 2.0 * t) / (2.0 * t * (1.0 - t))
82
-
83
-
84
- def flow_velocity(x0: torch.Tensor, x1: torch.Tensor, x: torch.Tensor, t: torch.Tensor,
85
- epsilon: float = EPSILON) -> torch.Tensor:
86
- """u_t - (epsilon / 2) grad log p_t, the velocity the first network regresses onto."""
87
- return (conditional_drift(x1, x, t)
88
- - 0.5 * epsilon * conditional_score(x0, x1, x, t, epsilon))
89
-
90
-
91
- def flow_velocity_collected(x0: torch.Tensor, x1: torch.Tensor, x: torch.Tensor,
92
- t: torch.Tensor) -> torch.Tensor:
93
- """(x_1 - x_0) + (1 - 2t) / (2 t (1 - t)) (x - m_t), the same velocity with delta collected."""
94
- return (x1 - x0) + flow_coefficient(t) * (x - bridge_mean(x0, x1, t))
95
-
96
-
97
- def scaled_score(x0: torch.Tensor, x1: torch.Tensor, x: torch.Tensor, t: torch.Tensor,
98
- epsilon: float = EPSILON) -> torch.Tensor:
99
- """sigma_t grad log p_t = (m_t - x) / sigma_t = -z, which has unit scale at every time."""
100
- return bridge_std(t, epsilon) * conditional_score(x0, x1, x, t, epsilon)
101
-
102
-
103
- # %% 05. The loss and the sampler
104
- #
105
- # With lambda(t) = sigma_t the score target is -z, so no part of the schedule dominates the
106
- # gradient. At sampling time a noise level g(t) is fixed and the two networks are integrated
107
- # together, with g^2 = epsilon recovering the reference noise and g = 0 the probability flow.
108
- # Section 7.19.3.
109
-
110
-
111
- def sf2m_loss(velocity: torch.Tensor, score: torch.Tensor, velocity_target: torch.Tensor,
112
- score_target: torch.Tensor, t: torch.Tensor, epsilon: float = EPSILON
113
- ) -> torch.Tensor:
114
- """||v_theta - u^o_t||^2 + lambda(t)^2 ||s_theta - grad log p_t||^2, with lambda(t) = sigma_t."""
115
- weight = bridge_std(t, epsilon) ** 2 # lambda(t)^2, which turns the score target into -z.
116
- return ((velocity - velocity_target) ** 2 + weight * (score - score_target) ** 2).mean()
117
-
118
-
119
- def sampler_drift(velocity: torch.Tensor, score: torch.Tensor, noise_level: float
120
- ) -> torch.Tensor:
121
- """v_theta + (1/2) g(t)^2 s_theta, the drift of the family of samplers between the two ends."""
122
- return velocity + 0.5 * noise_level ** 2 * score
123
-
124
-
125
- # %% 06. The role of the coupling
126
- #
127
- # Pairs from the entropic plan at strength 2 epsilon make the learned equation at g^2 = epsilon the
128
- # Schrodinger bridge, and pairs from any other coupling make it the Markov projection of that
129
- # mixture of bridges. The plan is computed on a minibatch. Section 7.19.3.
130
-
131
-
132
- def entropic_plan(x: torch.Tensor, y: torch.Tensor, a: torch.Tensor, b: torch.Tensor,
133
- epsilon: float = EPSILON, sweeps: int = 200) -> torch.Tensor:
134
- """The entropic plan for the squared cost at strength 2 epsilon, by Sinkhorn on exp(-C / 2 eps)."""
135
- C = (x[:, None] - y[None, :]) ** 2 # [n, m] the squared cost of the batch.
136
- K = torch.exp(-C / (2.0 * epsilon)) # The Gibbs kernel a Brownian reference produces.
137
- u, v = torch.ones_like(a), torch.ones_like(b)
138
- for _ in range(sweeps):
139
- u = a / (K @ v)
140
- v = b / (K.T @ u)
141
- return u[:, None] * K * v[None, :] # [n, m].
142
-
143
-
144
- # %% 07. The report
145
- def main() -> None:
146
- parser = common_parser(__doc__.split("\n")[0])
147
- parser.set_defaults(steps=101) # --steps is the number of times on the grid.
148
- args = parser.parse_args()
149
- set_seed(args.seed)
150
- loud = not args.quiet
151
- x0 = torch.tensor(X0, dtype=DTYPE)
152
- x1 = torch.tensor(X1, dtype=DTYPE)
153
- z = torch.tensor(DRAW, dtype=DTYPE)
154
-
155
- worked = {}
156
- for value in (0.25, 0.75):
157
- t = torch.tensor(value, dtype=DTYPE)
158
- x = bridge_sample(x0, x1, t, z)
159
- worked[value] = {
160
- "mean": float(bridge_mean(x0, x1, t)),
161
- "std": float(bridge_std(t)),
162
- "sample": float(x),
163
- "drift": float(conditional_drift(x1, x, t)),
164
- "score": float(conditional_score(x0, x1, x, t)),
165
- "flow": float(flow_velocity(x0, x1, x, t)),
166
- "collected": float(flow_velocity_collected(x0, x1, x, t)),
167
- "scaled": float(scaled_score(x0, x1, x, t)),
168
- "coefficient": float(flow_coefficient(t)),
169
- }
170
- if loud:
171
- w = worked[value]
172
- print(f"t = {value}, z = {DRAW}")
173
- print(f" m_t = {w['mean']:.4f} sigma_t = {w['std']:.4f} x = {w['sample']:.4f}")
174
- print(f" u_t = {w['drift']:.4f} grad log p_t = {w['score']:.4f}")
175
- print(f" u^o_t = {w['flow']:.4f} collected form {w['collected']:.4f} "
176
- f"coefficient {w['coefficient']:+.4f}")
177
- print(f" sigma_t grad log p_t = {w['scaled']:.4f}, which is -z")
178
-
179
- grid = torch.linspace(0.0, 1.0, max(args.steps, 3), dtype=DTYPE)[1:-1] # Interior times.
180
- samples = bridge_sample(x0, x1, grid, z) # [levels] one bridge sample per time.
181
- scaled = scaled_score(x0, x1, samples, grid)
182
- scaled_gap = float((scaled + z).abs().max()) # The target is -z at every time.
183
- flow_targets = flow_velocity(x0, x1, samples, grid)
184
- score_targets = conditional_score(x0, x1, samples, grid)
185
- loss_exact = float(sf2m_loss(flow_targets, score_targets, flow_targets, score_targets, grid))
186
- loss_zero = float(sf2m_loss(torch.zeros_like(flow_targets), torch.zeros_like(score_targets),
187
- flow_targets, score_targets, grid)) # An untrained pair of networks.
188
- stratified = torch.special.ndtri(
189
- (torch.arange(max(args.steps, 3), dtype=DTYPE) + 0.5) / max(args.steps, 3))
190
- half = torch.tensor(0.5, dtype=DTYPE)
191
- averaged = float(flow_velocity(x0, x1, bridge_sample(x0, x1, half, stratified), half).mean())
192
- symmetry = worked[0.25]["flow"] + worked[0.75]["flow"] - 2.0 * (X1 - X0)
193
- if loud:
194
- print(f"\nOn {len(grid)} interior times")
195
- print(f" largest gap between sigma_t grad log p_t and -z: {scaled_gap:.2e}")
196
- print(f" u^o averaged over the draw at t = 1/2: {averaged:.4f}, "
197
- f"against the straight-line velocity {X1 - X0:.0f}")
198
- print(f" the two flow targets about the straight-line velocity: {symmetry:.2e}")
199
- print(f" loss at the exact targets {loss_exact:.2e}, at a zero network {loss_zero:.4f}")
200
-
201
- x = torch.tensor([0.0, 2.0], dtype=DTYPE) # The batch of Section 7.8.1.
202
- y = torch.tensor([1.0, 3.0], dtype=DTYPE)
203
- uniform = torch.full((2,), 0.5, dtype=DTYPE)
204
- C = (x[:, None] - y[None, :]) ** 2
205
- plan = entropic_plan(x, y, uniform, uniform)
206
- ordered_cost = float(C[0, 0] + C[1, 1]) # 1 + 1, the arrows of the exact pairing.
207
- crossed_cost = float(C[0, 1] + C[1, 0]) # 9 + 1, the arrows of the crossed pairing.
208
- if loud:
209
- print(f"\nThe two-point batch at epsilon = {EPSILON:g}")
210
- print(f" exact transport pairs 0 with 1 and 2 with 3 at cost {ordered_cost:.0f}, "
211
- f"against {crossed_cost:.0f} for the crossed pairing")
212
- print(f" entropic plan at strength {2 * EPSILON:g}: "
213
- f"{[[round(float(z_), 6) for z_ in row] for row in plan]}")
214
- print(f" mass on the crossed pairing {float(plan[0, 1] + plan[1, 0]):.6f}")
215
-
216
- report = {
217
- "m_t at t = 0.25": worked[0.25]["mean"],
218
- "sigma_t at t = 0.25": worked[0.25]["std"],
219
- "bridge sample at t = 0.25": worked[0.25]["sample"],
220
- "u_t at t = 0.25": worked[0.25]["drift"],
221
- "grad log p_t at t = 0.25": worked[0.25]["score"],
222
- "u^o_t at t = 0.25": worked[0.25]["flow"],
223
- "coefficient at t = 0.25": worked[0.25]["coefficient"],
224
- "sigma_t grad log p_t at t = 0.25": worked[0.25]["scaled"],
225
- "bridge sample at t = 0.75": worked[0.75]["sample"],
226
- "u_t at t = 0.75": worked[0.75]["drift"],
227
- "u^o_t at t = 0.75": worked[0.75]["flow"],
228
- "coefficient at t = 0.75": worked[0.75]["coefficient"],
229
- "the two targets, sum minus 2 u": symmetry,
230
- "largest gap of the scaled score": scaled_gap,
231
- "u^o averaged over the draw": averaged,
232
- "loss at the exact targets": loss_exact,
233
- "loss at a zero network": loss_zero,
234
- "sampler drift at g^2 = epsilon": float(sampler_drift(
235
- torch.tensor(worked[0.25]["flow"], dtype=DTYPE),
236
- torch.tensor(worked[0.25]["score"], dtype=DTYPE), EPSILON ** 0.5)),
237
- "sampler drift at g = 0": worked[0.25]["flow"],
238
- "ordered pairing of the batch": ordered_cost,
239
- "crossed pairing of the batch": crossed_cost,
240
- "entropic plan, ordered mass": float(plan[0, 0] + plan[1, 1]),
241
- "entropic plan, crossed mass": float(plan[0, 1] + plan[1, 0]),
242
- }
243
- print_report("Simulation-Free Score and Flow Matching, Chapter 7 Section 7.19.3", report)
244
- save_run(args.out, config=vars(args), report=report) # Written only with --out.
245
-
246
-
247
- if __name__ == "__main__":
248
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_7/sinkhorn.py DELETED
@@ -1,234 +0,0 @@
1
- """Entropic transport and Sinkhorn's algorithm on the chapter's two-by-two example.
2
-
3
- Subtracting epsilon times the entropy from the transport cost makes the objective strictly convex,
4
- and its minimizer is the Gibbs kernel exp(-C/epsilon) with one scaling per row and one per column.
5
- The two marginal constraints determine the scalings by alternate rescaling, which is one relative
6
- entropy projection per half-step.
7
-
8
- Chapter 7, Sections 7.9 and 7.10 of the notes. Runs on a = (0.6, 0.4), b = (0.3, 0.7) and the cost
9
- matrix with entries 1, 9, 1, 1. Prints the kernel, the first sweep by hand, the marginal error after
10
- each sweep, the converged plan and its multipliers, and the plan at four values of epsilon.
11
-
12
- python lecture_7/sinkhorn.py # 40 sweeps, every worked number of Section 7.10
13
- python lecture_7/sinkhorn.py --steps 1 # the first sweep alone, computed by hand
14
- """
15
-
16
- # %% 01. Imports and configuration
17
- from __future__ import annotations
18
-
19
- import torch
20
-
21
- from cis6270.runner import common_parser, print_report, save_run, set_seed
22
-
23
- DTYPE = torch.float64 # The chapter quotes six figures of the kernel and of the converged plan.
24
-
25
- COST = torch.tensor([[1.0, 9.0], [1.0, 1.0]], dtype=DTYPE) # C of Section 7.3.1.
26
- SOURCE_A = torch.tensor([0.6, 0.4], dtype=DTYPE) # Row sums.
27
- TARGET_B = torch.tensor([0.3, 0.7], dtype=DTYPE) # Column sums.
28
- EXACT_PLAN = torch.tensor([[0.3, 0.3], [0.0, 0.4]], dtype=DTYPE) # pi* of Section 7.3.2.
29
- EPSILON = 2.0 # The strength the chapter runs by hand.
30
-
31
-
32
- # %% 02. Entropy of a plan, and the entropic objective
33
- #
34
- # Entropy is larger when the mass spreads over many routes and smaller when it concentrates, with
35
- # the convention 0 log 0 = 0. Subtracting epsilon H(pi) from the cost makes the objective strictly
36
- # convex, so the minimizer is unique and every entry of it is positive. Section 7.9.
37
-
38
-
39
- def entropy(pi: torch.Tensor) -> float:
40
- """H(pi) = -sum_ij pi_ij log pi_ij, in nats, with an empty route contributing nothing."""
41
- positive = pi[pi > 0] # Drop the zero entries, which contribute 0 log 0 = 0.
42
- return float(-(positive * positive.log()).sum())
43
-
44
-
45
- def entropic_objective(pi: torch.Tensor, C: torch.Tensor, epsilon: float) -> float:
46
- """<C, pi> - epsilon H(pi), the quantity the entropic problem minimizes over Pi(a, b)."""
47
- return float((C * pi).sum()) - epsilon * entropy(pi)
48
-
49
-
50
- # %% 03. Relative entropy from a positive matrix
51
- #
52
- # The definition keeps its meaning when xi is any strictly positive matrix. Taking xi to be the
53
- # independent coupling splits the logarithm into three pieces and gives -H(pi) + H(a) + H(b), so
54
- # the entropy term and a relative entropy from independence differ by a constant on Pi(a, b).
55
- # Section 7.9.1.
56
-
57
-
58
- def relative_entropy(pi: torch.Tensor, xi: torch.Tensor) -> float:
59
- """KL(pi || xi) = sum_ij pi_ij log(pi_ij / xi_ij) for a strictly positive xi."""
60
- mask = pi > 0 # Routes with no mass contribute nothing.
61
- return float((pi[mask] * (pi[mask] / xi[mask]).log()).sum())
62
-
63
-
64
- def independent_coupling(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
65
- """a_i b_j, the coupling of largest entropy and the one Chapters 2 through 6 used."""
66
- return a[:, None] * b[None, :] # [n, m].
67
-
68
-
69
- # %% 04. The Lagrangian and the scaling form
70
- #
71
- # Setting the derivative of the Lagrangian in a single entry to zero gives
72
- # C_ij + epsilon (log pi_ij + 1) - f_i - g_j = 0, and solving for the logarithm splits the
73
- # minimizer into a row factor, a kernel entry and a column factor. Section 7.9.2.
74
-
75
-
76
- def gibbs_kernel(C: torch.Tensor, epsilon: float) -> torch.Tensor:
77
- """K = exp(-C / epsilon), taken entrywise, with larger entries on the cheaper routes."""
78
- return torch.exp(-C / epsilon) # [n, m], every entry strictly positive.
79
-
80
-
81
- def scaling_form(u: torch.Tensor, K: torch.Tensor, v: torch.Tensor) -> torch.Tensor:
82
- """diag(u) K diag(v), the plan with row i scaled by u_i and column j scaled by v_j."""
83
- return u[:, None] * K * v[None, :] # [n, m].
84
-
85
-
86
- def multipliers_from_scalings(u: torch.Tensor, epsilon: float) -> torch.Tensor:
87
- """Invert u_i = exp(f_i / epsilon - 1/2) for f_i = epsilon (log u_i + 1/2)."""
88
- return epsilon * (u.log() + 0.5) # [n] the additive price behind a multiplicative scaling.
89
-
90
-
91
- # %% 05. The two updates
92
- #
93
- # The row sums of diag(u) K diag(v) are u * (K v) and the column sums are v * (K^T u), and solving
94
- # each equation for its own scaling with the other held fixed gives the two updates. One sweep is
95
- # the pair of them, at two matrix-vector products. Section 7.10.
96
-
97
-
98
- def sinkhorn_sweeps(
99
- K: torch.Tensor, a: torch.Tensor, b: torch.Tensor, sweeps: int,
100
- ) -> tuple[torch.Tensor, torch.Tensor, list[float]]:
101
- """Alternate u <- a / (K v) and v <- b / (K^T u), returning the scalings and the sweep errors."""
102
- u = torch.ones_like(a)
103
- v = torch.ones_like(b)
104
- errors: list[float] = []
105
- for _ in range(sweeps):
106
- u = a / (K @ v) # Row sums become exactly a.
107
- v = b / (K.T @ u) # Column sums become exactly b, and the row sums move away again.
108
- errors.append(marginal_error(scaling_form(u, K, v), a, b))
109
- return u, v, errors
110
-
111
-
112
- def marginal_error(pi: torch.Tensor, a: torch.Tensor, b: torch.Tensor) -> float:
113
- """Total absolute error of the row sums against a plus that of the column sums against b."""
114
- return float((pi.sum(dim=1) - a).abs().sum() + (pi.sum(dim=0) - b).abs().sum())
115
-
116
-
117
- def sweeps_to_tolerance(K: torch.Tensor, a: torch.Tensor, b: torch.Tensor,
118
- tolerance: float = 1e-9, limit: int = 2000) -> int:
119
- """The number of sweeps that brings the total marginal error below the tolerance."""
120
- _, _, errors = sinkhorn_sweeps(K, a, b, limit)
121
- for index, error in enumerate(errors):
122
- if error < tolerance:
123
- return index + 1
124
- return limit
125
-
126
-
127
- # %% 06. Each update as a relative entropy projection
128
- #
129
- # Minimizing KL(pi || xi) over the matrices with row sums a, with no condition on the columns, gives
130
- # pi_ij = xi_ij exp(lambda_i - 1), which is xi with each row rescaled, and the row sums fix the
131
- # factor at a_i / sum_j xi_ij. The u update performs exactly that rescaling. Section 7.10.1.
132
-
133
-
134
- def project_rows(xi: torch.Tensor, a: torch.Tensor) -> torch.Tensor:
135
- """The matrix with row sums a closest to xi in relative entropy."""
136
- return xi * (a / xi.sum(dim=1))[:, None] # One factor per row.
137
-
138
-
139
- def project_columns(xi: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
140
- """The matrix with column sums b closest to xi in relative entropy."""
141
- return xi * (b / xi.sum(dim=0))[None, :] # One factor per column.
142
-
143
-
144
- # %% 07. The report
145
- def main() -> None:
146
- parser = common_parser(__doc__.split("\n")[0])
147
- parser.add_argument("--epsilon", type=float, default=EPSILON, help="regularization strength")
148
- parser.set_defaults(steps=40) # --steps is the number of Sinkhorn sweeps.
149
- args = parser.parse_args()
150
- set_seed(args.seed)
151
- loud = not args.quiet
152
- epsilon = args.epsilon
153
-
154
- K = gibbs_kernel(COST, epsilon)
155
- independent = independent_coupling(SOURCE_A, TARGET_B)
156
- if loud:
157
- print(f"H(pi*) = {entropy(EXACT_PLAN):.3f} H(a x b) = {entropy(independent):.3f} "
158
- f"H(a) + H(b) = {entropy(SOURCE_A) + entropy(TARGET_B):.3f}")
159
- print(f"KL(pi* || a x b) = {relative_entropy(EXACT_PLAN, independent):.3f} nats")
160
- print(f"\nK = exp(-C/{epsilon:g}) = {[[round(float(k), 6) for k in row] for row in K]}")
161
- print(f" kernel ratio between a cheap route and the expensive one "
162
- f"{float(K[0, 0] / K[0, 1]):.1f}")
163
-
164
- # The first sweep, written out the way Section 7.10.2 writes it.
165
- v = torch.ones_like(TARGET_B)
166
- Kv = K @ v # Row sums of K, since v = (1, 1).
167
- u = SOURCE_A / Kv
168
- Ktu = K.T @ u
169
- v = TARGET_B / Ktu
170
- first = scaling_form(u, K, v)
171
- if loud:
172
- print(f"\nsweep 1 K v = {[round(float(z), 6) for z in Kv]}"
173
- f" u = {[round(float(z), 6) for z in u]}")
174
- print(f" K^T u = {[round(float(z), 6) for z in Ktu]}"
175
- f" v = {[round(float(z), 5) for z in v]}")
176
- print(f" diag(u) K diag(v) = {[[round(float(z), 4) for z in row] for row in first]}")
177
- print(f" column sums {[round(float(z), 4) for z in first.sum(0)]} "
178
- f"row sums {[round(float(z), 4) for z in first.sum(1)]}")
179
- after_u = project_rows(first, SOURCE_A) # The next update of u, as a row projection.
180
- print(f" after the next u update: rows {[round(float(z), 3) for z in after_u.sum(1)]} "
181
- f"columns {[round(float(z), 3) for z in after_u.sum(0)]}")
182
-
183
- u, v, errors = sinkhorn_sweeps(K, SOURCE_A, TARGET_B, max(args.steps, 1))
184
- plan = scaling_form(u, K, v)
185
- f = multipliers_from_scalings(u, epsilon)
186
- g = multipliers_from_scalings(v, epsilon)
187
- sums = f[:, None] + g[None, :] # The entropic counterparts of the dual prices.
188
- if loud:
189
- print(f"\nmarginal error by sweep {[round(e, 3) for e in errors[:5]]}")
190
- print(f" successive ratios "
191
- f"{[round(errors[i + 1] / errors[i], 2) for i in range(min(4, len(errors) - 1))]}")
192
- print(f" pi^eps = {[[round(float(z), 6) for z in row] for row in plan]}")
193
- print(f" u = {[round(float(z), 4) for z in u]} v = {[round(float(z), 4) for z in v]}")
194
- print(f" f = {[round(float(z), 3) for z in f]} g = {[round(float(z), 3) for z in g]}")
195
- print(f" f_i + g_j = {[round(float(z), 3) for z in sums.flatten()]} "
196
- f"against C = {[round(float(z), 0) for z in COST.flatten()]}")
197
- print(f" the route (2,2) carries {float(plan[1, 1]):.3f} and overshoots its cost by "
198
- f"{float(sums[1, 1] - COST[1, 1]):.3f}")
199
-
200
- print("\neps <C, pi^eps> pi^eps_21 sweeps to 1e-9")
201
- for value in (10.0, 2.0, 0.5, 0.1):
202
- kernel = gibbs_kernel(COST, value)
203
- su, sv, _ = sinkhorn_sweeps(kernel, SOURCE_A, TARGET_B, 400)
204
- p = scaling_form(su, kernel, sv)
205
- print(f"{value:<7g} {float((COST * p).sum()):<14.7f} {float(p[1, 0]):<16.3e} "
206
- f"{sweeps_to_tolerance(kernel, SOURCE_A, TARGET_B)}")
207
-
208
- report = {
209
- "H(pi*)": entropy(EXACT_PLAN),
210
- "H(a x b) = H(a) + H(b)": entropy(independent),
211
- "KL(pi* || a x b)": relative_entropy(EXACT_PLAN, independent),
212
- "K_11": float(K[0, 0]),
213
- "K_12": float(K[0, 1]),
214
- "sweep 1 marginal error": errors[0],
215
- "sweep 5 marginal error": errors[min(4, len(errors) - 1)],
216
- "sweeps to 1e-9": sweeps_to_tolerance(K, SOURCE_A, TARGET_B),
217
- "pi^eps_11": float(plan[0, 0]),
218
- "pi^eps_21": float(plan[1, 0]),
219
- "<C, pi^eps>": float((COST * plan).sum()),
220
- "<C, pi*>": float((COST * EXACT_PLAN).sum()),
221
- "H(pi^eps)": entropy(plan),
222
- "objective at pi^eps": entropic_objective(plan, COST, epsilon),
223
- "objective at pi*": entropic_objective(EXACT_PLAN, COST, epsilon),
224
- "objective at a x b": entropic_objective(independent, COST, epsilon),
225
- "KL(pi^eps || a x b)": relative_entropy(plan, independent),
226
- "f": [round(float(z), 3) for z in f],
227
- "g": [round(float(z), 3) for z in g],
228
- }
229
- print_report("Entropic transport and Sinkhorn, Chapter 7 Sections 7.9 and 7.10", report)
230
- save_run(args.out, config=vars(args), report=report) # Written only with --out.
231
-
232
-
233
- if __name__ == "__main__":
234
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lecture_7/static_bridge.py DELETED
@@ -1,295 +0,0 @@
1
- """The static Schrodinger bridge: a path law, the chain rule, and the scaling that solves it.
2
-
3
- A path law over a finite time grid is an ordinary probability vector over paths, and its relative
4
- entropy from a reference splits by the chain rule into a term for the two endpoints and a term for
5
- the interior. Only the first term is constrained, so the bridge keeps the reference bridges exactly
6
- and all of its freedom goes into the endpoint coupling, which iterative proportional fitting
7
- computes by alternate rescaling.
8
-
9
- Chapter 7, Sections 7.12 to 7.15.3 of the notes. Runs on the two-state chain with transition matrix
10
- A, source law (0.5, 0.5) and requested terminal law (0.2, 0.8). Prints the eight path probabilities,
11
- the chain rule on two tables, the fitted potentials, the tilted matrices and the bridge path law.
12
-
13
- python lecture_7/static_bridge.py # 4 rounds of proportional fitting
14
- python lecture_7/static_bridge.py --steps 1 # one round, the one worked by hand
15
- """
16
-
17
- # %% 01. Imports and configuration
18
- from __future__ import annotations
19
-
20
- import itertools
21
-
22
- import torch
23
-
24
- from cis6270.runner import common_parser, print_report, save_run, set_seed
25
-
26
- DTYPE = torch.float64 # The chapter quotes six figures of the potentials and of the path law.
27
-
28
- A = torch.tensor([[0.7, 0.3], [0.4, 0.6]], dtype=DTYPE) # The reference transition matrix.
29
- PI_0 = torch.tensor([0.5, 0.5], dtype=DTYPE) # The source law.
30
- PI_1 = torch.tensor([0.2, 0.8], dtype=DTYPE) # The requested terminal law after two steps.
31
- STEPS = 2 # N, the number of transitions, so a path is a triple and there are eight of them.
32
-
33
-
34
- # %% 02. The probability of a whole path
35
- #
36
- # For a Markov process the probability of a list of states is the starting mass times the product of
37
- # the transition probabilities along it. Section 7.12.1.
38
-
39
-
40
- def path_law(start: torch.Tensor, matrices: list[torch.Tensor]) -> dict[tuple[int, ...], float]:
41
- """R(x_{0:N}) over every path of the chain, as a dictionary keyed by the list of states."""
42
- size = len(start)
43
- law = {}
44
- for path in itertools.product(range(size), repeat=len(matrices) + 1):
45
- weight = float(start[path[0]]) # The starting mass R_0(x_0).
46
- for k, matrix in enumerate(matrices):
47
- weight *= float(matrix[path[k], path[k + 1]]) # One transition probability per step.
48
- law[path] = weight
49
- return law # K^(N+1) entries, summing to one.
50
-
51
-
52
- def relative_entropy(p: dict, q: dict) -> float:
53
- """KL(P || R) = sum over paths of P log(P / R), the definition of Chapter 1 indexed by paths."""
54
- return sum(v * torch.log(torch.tensor(v / q[k], dtype=DTYPE)).item()
55
- for k, v in p.items() if v > 0)
56
-
57
-
58
- # %% 03. The chain rule for relative entropy
59
- #
60
- # Factoring each joint law into a marginal and a conditional and averaging the logarithm of the
61
- # ratio splits the relative entropy into a marginal term and an averaged conditional term.
62
- # Sections 7.13 and 7.13.1.
63
-
64
-
65
- def kl_vectors(p: torch.Tensor, q: torch.Tensor) -> float:
66
- """Relative entropy between two probability vectors over the same states."""
67
- mask = p > 0
68
- return float((p[mask] * (p[mask] / q[mask]).log()).sum())
69
-
70
-
71
- def chain_rule_terms(pi: torch.Tensor, other: torch.Tensor) -> tuple[float, float, float]:
72
- """The total, the marginal term and the averaged conditional term of KL(pi || other)."""
73
- total = kl_vectors(pi.flatten(), other.flatten()) # The four cells, computed directly.
74
- marginal = kl_vectors(pi.sum(dim=1), other.sum(dim=1)) # The disagreement about the row.
75
- rows = [kl_vectors(pi[i] / pi[i].sum(), other[i] / other[i].sum()) for i in range(len(pi))]
76
- conditional = float((pi.sum(dim=1) * torch.tensor(rows, dtype=DTYPE)).sum())
77
- return total, marginal, conditional
78
-
79
-
80
- # %% 04. Splitting a path law at its two endpoints
81
- #
82
- # With the pair (x_0, x_N) in the place of the first coordinate and the interior in the place of the
83
- # second, the chain rule separates the endpoint coupling from the bridges. The second term is free
84
- # and nonnegative, so the bridge takes the reference bridges and the problem collapses onto the
85
- # coupling. Section 7.13.2.
86
-
87
-
88
- def endpoint_coupling(law: dict) -> torch.Tensor:
89
- """The law of the pair (x_0, x_N) under a path law, summed over the interior states."""
90
- coupling = torch.zeros(2, 2, dtype=DTYPE)
91
- for path, weight in law.items():
92
- coupling[path[0], path[-1]] += weight # Collect every path with the same two ends.
93
- return coupling
94
-
95
-
96
- def reference_bridge(x0: int, mid: int, xN: int, matrix: torch.Tensor,
97
- two_step: torch.Tensor) -> float:
98
- """R(x_1 | x_0, x_2) = A(x_0, x_1) A(x_1, x_2) / A^2(x_0, x_2), the interior given the ends."""
99
- return float(matrix[x0, mid] * matrix[mid, xN] / two_step[x0, xN])
100
-
101
-
102
- def fill_with_reference_bridges(coupling: torch.Tensor, matrix: torch.Tensor) -> dict:
103
- """P(x_{0:2}) = pi(x_0, x_2) R(x_1 | x_0, x_2), a coupling filled in with reference bridges."""
104
- two_step = matrix @ matrix
105
- return {(x0, mid, xN): float(coupling[x0, xN]) * reference_bridge(x0, mid, xN, matrix, two_step)
106
- for x0, mid, xN in itertools.product(range(2), repeat=3)}
107
-
108
-
109
- # %% 05. The static problem and the two potentials
110
- #
111
- # The bridge coupling minimizes KL(pi || R_01) over the couplings of pi_0 and pi_1, which is the
112
- # entropic problem with R_01 in place of the Gibbs kernel, so the minimizer is R_01 with one scaling
113
- # per row and one per column and the two marginals determine them by alternation.
114
- # Sections 7.14 and 7.14.2.
115
-
116
-
117
- def reference_endpoint_law(start: torch.Tensor, matrix: torch.Tensor, steps: int) -> torch.Tensor:
118
- """R_01 = diag(pi_0) A^N, the reference probability of starting at x and ending at y."""
119
- return torch.diag(start) @ torch.linalg.matrix_power(matrix, steps) # [K, K].
120
-
121
-
122
- def proportional_fitting(
123
- R01: torch.Tensor, start: torch.Tensor, end: torch.Tensor, rounds: int,
124
- ) -> tuple[torch.Tensor, torch.Tensor, list[float]]:
125
- """Alternate g <- pi_1 / (R_01^T f) and f <- pi_0 / (R_01 g), returning the terminal errors.
126
-
127
- One round is a g update followed by an f update, started from f = (1, 1), so the row sums are
128
- exact at the end of a round and the error is measured on the columns.
129
- """
130
- f = torch.ones_like(start)
131
- g = torch.ones_like(end)
132
- errors: list[float] = []
133
- for _ in range(rounds):
134
- g = end / (R01.T @ f) # Column sums become exactly pi_1.
135
- f = start / (R01 @ g) # Row sums become exactly pi_0, and the columns move again.
136
- plan = f[:, None] * R01 * g[None, :]
137
- errors.append(float((plan.sum(dim=0) - end).abs().sum()))
138
- return f, g, errors
139
-
140
-
141
- # %% 06. The squared cost from a Brownian reference
142
- #
143
- # For a Brownian motion of variance epsilon the transition density is a Gibbs kernel for the squared
144
- # distance at strength 2 epsilon, so minimizing the relative entropy from its endpoint law is the
145
- # entropic transport problem with the noise variance in the place of the regularization strength.
146
- # Section 7.14.1.
147
-
148
-
149
- def brownian_reference_plan(epsilon: float = 1.0) -> torch.Tensor:
150
- """The bridge coupling of the four points of Section 7.3.1 under a Brownian reference."""
151
- x = torch.tensor([0.0, 2.0], dtype=DTYPE) # Source points.
152
- y = torch.tensor([1.0, 3.0], dtype=DTYPE) # Target points.
153
- a = torch.tensor([0.6, 0.4], dtype=DTYPE) # Their masses.
154
- b = torch.tensor([0.3, 0.7], dtype=DTYPE)
155
- endpoint_law = torch.exp(-(x[:, None] - y[None, :]) ** 2 / (2.0 * epsilon)) # exp(-C/2).
156
- f, g, _ = proportional_fitting(endpoint_law, a, b, 200)
157
- return f[:, None] * endpoint_law * g[None, :] # [2, 2].
158
-
159
-
160
- # %% 07. Making the bridge a Markov process
161
- #
162
- # The backward potential averages g over the reference, one matrix-vector product per step, and
163
- # reweighting each transition by the ratio of the potential at the destination to the potential at
164
- # the current state produces a transition matrix whose rows sum to one by that same recursion.
165
- # Sections 7.15 and 7.15.1.
166
-
167
-
168
- def backward_potential(matrix: torch.Tensor, g: torch.Tensor, steps: int) -> list[torch.Tensor]:
169
- """h_N = g and h_k = A h_{k+1}, returned as the list h_0, ..., h_N."""
170
- potentials = [g]
171
- for _ in range(steps):
172
- potentials.insert(0, matrix @ potentials[0]) # One step back, into the front of the list.
173
- return potentials
174
-
175
-
176
- def tilted_matrix(matrix: torch.Tensor, h_now: torch.Tensor, h_next: torch.Tensor) -> torch.Tensor:
177
- """A^h_k(x, y) = A(x, y) h_{k+1}(y) / h_k(x), Doob's h-transform of one step."""
178
- return matrix * h_next[None, :] / h_now[:, None] # [K, K], each row summing to one.
179
-
180
-
181
- # %% 08. The report
182
- def main() -> None:
183
- parser = common_parser(__doc__.split("\n")[0])
184
- parser.set_defaults(steps=4) # --steps is the number of proportional fitting rounds.
185
- args = parser.parse_args()
186
- set_seed(args.seed)
187
- loud = not args.quiet
188
- rounds = max(args.steps, 1)
189
-
190
- reference = path_law(PI_0, [A] * STEPS)
191
- if loud:
192
- print("The eight paths of the two-state chain, with their reference probabilities")
193
- for path, weight in reference.items():
194
- print(f" {','.join(str(s + 1) for s in path)} R = {weight:.6f}")
195
- print(f" they sum to {sum(reference.values()):.6f}")
196
-
197
- table = torch.tensor([[0.5, 0.1], [0.1, 0.3]], dtype=DTYPE) # pi of Section 7.13.1.
198
- uniform = torch.full((2, 2), 0.25, dtype=DTYPE) # pi', the uniform law on the four cells.
199
- total, marginal, conditional = chain_rule_terms(table, uniform)
200
- if loud:
201
- print(f"\nChain rule on two tables: total {total:.4f} = marginal {marginal:.4f} "
202
- f"+ conditional {conditional:.4f}")
203
-
204
- R01 = reference_endpoint_law(PI_0, A, STEPS)
205
- reference_error = float((R01.sum(dim=0) - PI_1).abs().sum())
206
- f, g, errors = proportional_fitting(R01, PI_0, PI_1, rounds)
207
- plan = f[:, None] * R01 * g[None, :]
208
- independent = PI_0[:, None] * PI_1[None, :]
209
- if loud:
210
- print(f"\nA^2 = {[[round(float(z), 2) for z in row] for row in A @ A]}")
211
- print(f"R_01 = {[[round(float(z), 3) for z in row] for row in R01]} "
212
- f"column sums {[round(float(z), 3) for z in R01.sum(0)]} error {reference_error:.2f}")
213
- first_g = PI_1 / R01.sum(dim=0) # The first update, with f = (1, 1).
214
- print(f" first g = {[round(float(z), 4) for z in first_g]} "
215
- f"R_01 g = {[round(float(z), 4) for z in R01 @ first_g]}")
216
- print(f" first f = {[round(float(z), 4) for z in PI_0 / (R01 @ first_g)]}")
217
- print(" round f g terminal error ratio")
218
- fr = torch.ones_like(PI_0)
219
- for index in range(rounds):
220
- gr = PI_1 / (R01.T @ fr)
221
- fr = PI_0 / (R01 @ gr)
222
- previous = reference_error if index == 0 else errors[index - 1]
223
- ratio = f"{previous / errors[index]:.1f}" if errors[index] > 0 else "exact"
224
- print(f" {index + 1:<7d} ({float(fr[0]):.6f}, {float(fr[1]):.6f}) "
225
- f"({float(gr[0]):.6f}, {float(gr[1]):.6f}) {errors[index]:.3e} {ratio}")
226
- print(f" pi* = {[[round(float(z), 6) for z in row] for row in plan]}")
227
- print(f" KL(pi* || R_01) = {kl_vectors(plan.flatten(), R01.flatten()):.4f} "
228
- f"KL(independent || R_01) = {kl_vectors(independent.flatten(), R01.flatten()):.4f}")
229
-
230
- brownian = brownian_reference_plan()
231
- if loud:
232
- print("\nBrownian reference at epsilon = 1 on the four points of Section 7.3.1")
233
- print(f" pi* = {[[round(float(z), 4) for z in row] for row in brownian]}, the plan the "
234
- f"Gibbs kernel at strength 2 produced")
235
-
236
- potentials = backward_potential(A, g, STEPS)
237
- tilted = [tilted_matrix(A, potentials[k], potentials[k + 1]) for k in range(STEPS)]
238
- middle = PI_0 @ tilted[0]
239
- terminal = middle @ tilted[1]
240
- bridge_law = fill_with_reference_bridges(plan, A)
241
- bridge_kl = relative_entropy(bridge_law, reference)
242
- middle_from_paths = sum(v for k, v in bridge_law.items() if k[1] == 0)
243
- if loud:
244
- print()
245
- for k, h in enumerate(potentials):
246
- print(f"h_{k} = ({float(h[0]):.6f}, {float(h[1]):.6f}) "
247
- f"ratio {float(h[1] / h[0]):.2f}")
248
- for k, matrix in enumerate(tilted):
249
- print(f"A^h_{k} = {[[round(float(z), 6) for z in row] for row in matrix]}")
250
- print(f" (0.5, 0.5) -> {[round(float(z), 6) for z in middle]} -> "
251
- f"{[round(float(z), 6) for z in terminal]}")
252
- print(f" 1 / h_0 = {[round(float(z), 6) for z in 1.0 / potentials[0]]}, "
253
- f"the f the scaling produced")
254
- product = torch.diag(PI_0) @ tilted[0] @ tilted[1] # The endpoint law of the tilted chain.
255
- print(f" diag(pi_0) A^h_0 A^h_1 = "
256
- f"{[[round(float(z), 6) for z in row] for row in product]}")
257
- print("\npath R P* P*/R")
258
- for path in reference:
259
- name = ",".join(str(s + 1) for s in path)
260
- print(f" {name} {reference[path]:.6f} {bridge_law[path]:.6f} "
261
- f"{bridge_law[path] / reference[path]:.6f}")
262
-
263
- report = {
264
- "R(1,1,2)": reference[(0, 0, 1)],
265
- "the eight paths sum to": sum(reference.values()),
266
- "chain rule, total": total,
267
- "chain rule, marginal term": marginal,
268
- "chain rule, conditional term": conditional,
269
- "reference terminal error": reference_error,
270
- "terminal error, round 1": errors[0],
271
- "terminal error, last round": errors[-1],
272
- "error ratio per round": reference_error / errors[0],
273
- "f": [round(float(z), 6) for z in f],
274
- "g": [round(float(z), 6) for z in g],
275
- "pi*_11": float(plan[0, 0]),
276
- "pi*_12": float(plan[0, 1]),
277
- "KL(pi* || R_01)": kl_vectors(plan.flatten(), R01.flatten()),
278
- "KL(pi_0 x pi_1 || R_01)": kl_vectors(independent.flatten(), R01.flatten()),
279
- "h_0": [round(float(z), 6) for z in potentials[0]],
280
- "h_2 = g": [round(float(z), 6) for z in potentials[-1]],
281
- "A^h_0(1,1)": float(tilted[0][0, 0]),
282
- "A^h_1(1,2)": float(tilted[1][0, 1]),
283
- "rows of A^h_0 sum to": float(tilted[0].sum(dim=1).max()),
284
- "marginal at the middle step": float(middle[0]),
285
- "marginal at the last step": [round(float(z), 6) for z in terminal],
286
- "f against 1 / h_0": float((f - 1.0 / potentials[0]).abs().max()),
287
- "KL(P* || R) over the paths": bridge_kl,
288
- "middle state from the paths": middle_from_paths,
289
- }
290
- print_report("The static Schrodinger bridge, Chapter 7 Sections 7.12 to 7.15.3", report)
291
- save_run(args.out, config=vars(args), report=report) # Written only with --out.
292
-
293
-
294
- if __name__ == "__main__":
295
- main()