File size: 12,259 Bytes
0ba9d09
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
"""Transport as motion in time: the kinetic action, straight lines, and displacement interpolation.

A plan records which source point ends where and nothing about the trajectory between them. The
Benamou-Brenier formulation replaces the plan by a density and a velocity field and measures the
transport by the energy the particles expend. Its minimizer moves every particle in a straight line
at constant speed, with the endpoints paired by the optimal plan.

Chapter 7, Sections 7.7 to 7.7.5 of the notes. Runs on the one-particle example of Section 7.7.1
and on the Gaussian pair N(0,1) to N(3,4), both in closed form. Prints the action of a steady and a
dawdling particle, the two sides of the averaged bound, the interpolated density against the mixture
of the endpoints, and the velocity field with its action density.

    python lecture_7/displacement.py              # 1024 times on the grid
    python lecture_7/displacement.py --steps 20   # a coarse time grid, for a first look
"""

# %% 01. Imports and configuration
from __future__ import annotations

import math

import torch

from cis6270.runner import common_parser, print_report, save_run, set_seed

DTYPE = torch.float64  # Double precision, so the closed forms match the chapter digit for digit.

MEAN_0, STD_0 = 0.0, 1.0  # The source N(0, 1).
MEAN_1, STD_1 = 3.0, 2.0  # The target N(3, 4), with standard deviation 2.


# %% 02. One particle
#
# The squared displacement of a particle is at most the integral of its squared speed, by
# Cauchy-Schwarz against the constant function 1 on an interval of length 1, with equality when the
# velocity is constant in time. Section 7.7.1.


def action_of_a_path(positions: torch.Tensor, times: torch.Tensor) -> float:
    """The integral of the squared speed along a trajectory sampled at the given times."""
    velocity = (positions[1:] - positions[:-1]) / (times[1:] - times[:-1])  # [n-1] piecewise speed.
    return float((velocity ** 2 * (times[1:] - times[:-1])).sum())  # Integral of ||x_dot||^2.


def steady_particle(times: torch.Tensor) -> torch.Tensor:
    """A particle covering the distance 2 at the constant speed 2, so its action is 4."""
    return 2.0 * times  # x(t) = 2t.


def dawdling_particle(times: torch.Tensor) -> torch.Tensor:
    """A particle that waits until t = 1/2 and then covers the same distance at speed 4."""
    return torch.clamp(4.0 * (times - 0.5), min=0.0)  # Zero on [0, 1/2], then 4(t - 1/2).


# %% 03. Averaging the bound over the particles
#
# The joint law of the two endpoints of a particle is a coupling of mu and nu, so the action is at
# least the average squared displacement under that coupling, and that is at least W_2^2.
# Section 7.7.2.


def average_squared_displacement(x: torch.Tensor, y: torch.Tensor, pi: torch.Tensor) -> float:
    """The average of ||x_1 - x_0||^2 over endpoint pairs drawn from the coupling pi."""
    return float((pi * (x[:, None] - y[None, :]) ** 2).sum())  # Sum over routes of mass times cost.


# %% 04. Straight lines
#
# We draw the endpoint pair from the optimal coupling and move along the segment between them.
# The velocity is constant along it. Cauchy-Schwarz is then tight for every particle, and the
# coupling is optimal, so the second inequality is tight as well. Section 7.7.3.


def straight_path(x0: torch.Tensor, x1: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
    """x(t) = (1 - t) x_0 + t x_1, with the constant velocity x_1 - x_0."""
    return (1.0 - t) * x0 + t * x1  # Same shape as x0 and x1 broadcast against t.


# %% 05. Displacement interpolation
#
# We read off the law of the particles at an intermediate time and get the displacement
# interpolation. Under the optimal map X_1 = 3 + 2 X_0 of the Gaussian example, X_t collects into
# 3t + (1 + t) X_0, so rho_t is Gaussian with mean 3t and standard deviation 1 + t. Section 7.7.4.


def displacement_law(t: float) -> tuple[float, float]:
    """The mean and standard deviation of rho_t for the Gaussian pair, both linear in t."""
    return 3.0 * t, 1.0 + t  # Mean slides from 0 to 3, standard deviation from 1 to 2.


def normal_density(x: torch.Tensor, mean: float, std: float) -> torch.Tensor:
    """The density of N(mean, std^2) evaluated at every entry of x."""
    return torch.exp(-0.5 * ((x - mean) / std) ** 2) / (std * math.sqrt(2.0 * math.pi))


def mixture_density(x: torch.Tensor) -> torch.Tensor:
    """Half the source density plus half the target density, the other path between the two ends."""
    return 0.5 * normal_density(x, MEAN_0, STD_0) + 0.5 * normal_density(x, MEAN_1, STD_1)


def gaussian_w2(m0: float, s0: float, m1: float, s1: float) -> float:
    """W_2 between two Gaussians on the line, the closed form of Section 7.6."""
    return math.sqrt((m0 - m1) ** 2 + (s0 - s1) ** 2)


# %% 06. The velocity field behind the displacement path
#
# Each particle obeys X_t = 3t + (1 + t) X_0 with the constant velocity 3 + X_0. We solve the
# trajectory for X_0 and substitute. The velocity is then a function of the current position
# alone. Section 7.7.5.


def velocity_field(x: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
    """v_t(x) = 3 + (x - 3t) / (1 + t), the mean speed plus a stretch about the mean."""
    return 3.0 + (x - 3.0 * t) / (1.0 + t)  # Broadcasts over a grid of states and times.


def continuity_residual(x: float, t: float) -> float:
    """The left side of d_t rho_t + d_x (rho_t v_t) = 0, computed by automatic differentiation."""
    xs = torch.tensor(x, dtype=DTYPE, requires_grad=True)
    ts = torch.tensor(t, dtype=DTYPE, requires_grad=True)
    rho = normal_density(xs, 3.0 * ts, 1.0 + ts)  # rho_t written with the two linear schedules.
    flux = rho * velocity_field(xs, ts)  # The probability flux rho_t v_t.
    d_rho_dt = torch.autograd.grad(rho, ts, retain_graph=True)[0]  # Keep the graph for the flux.
    d_flux_dx = torch.autograd.grad(flux, xs)[0]
    return float((d_rho_dt + d_flux_dx).detach())


def action_density(t: float, levels: int) -> float:
    """The integral of rho_t(x) v_t(x)^2 over x, or E[(3 + X_0)^2] at every time."""
    mean, std = displacement_law(t)
    q = (torch.arange(levels, dtype=DTYPE) + 0.5) / levels  # Quantile levels of rho_t.
    x = mean + std * torch.special.ndtri(q)  # [levels] a stratified sample of rho_t.
    speed = velocity_field(x, torch.tensor(t, dtype=DTYPE))  # [levels].
    return float((speed ** 2).mean())  # The average of the squared speed under rho_t.


# %% 07. The report
def main() -> None:
    parser = common_parser(__doc__.split("\n")[0])
    parser.set_defaults(steps=1024)  # --steps is the number of times on the grid.
    args = parser.parse_args()
    set_seed(args.seed)
    loud = not args.quiet
    n = max(args.steps, 2)
    times = torch.linspace(0.0, 1.0, n + 1, dtype=DTYPE)  # [n+1] the time grid.

    steady = action_of_a_path(steady_particle(times), times)
    dawdle = action_of_a_path(dawdling_particle(times), times)
    if loud:
        print(f"One particle from 0 to 2 on a grid of {n} steps")
        print(f"  constant speed 2: action {steady:.3f}, squared displacement {2.0 ** 2:.0f}")
        print(f"  waiting then speed 4: action {dawdle:.3f}, the same two endpoints")

    x = torch.tensor([0.0, 2.0], dtype=DTYPE)  # Four points, mass 1/2 each.
    y = torch.tensor([1.0, 3.0], dtype=DTYPE)
    ordered = torch.tensor([[0.5, 0.0], [0.0, 0.5]], dtype=DTYPE)
    crossed = torch.tensor([[0.0, 0.5], [0.5, 0.0]], dtype=DTYPE)
    independent = torch.full((2, 2), 0.25, dtype=DTYPE)
    w22_points = average_squared_displacement(x, y, ordered)  # The optimal coupling on the line.
    if loud:
        print(f"\nAveraging over particles, four points with mass 1/2")
        for name, pi in (("ordered", ordered), ("independent", independent), ("crossed", crossed)):
            print(f"  {name:<12s} E||x_1 - x_0||^2 = "
                  f"{average_squared_displacement(x, y, pi):.1f}  >= W_2^2 = {w22_points:.1f}")

    # Straight lines under the optimal coupling, with the endpoints of the Gaussian example.
    q = (torch.arange(n, dtype=DTYPE) + 0.5) / n
    x0 = MEAN_0 + STD_0 * torch.special.ndtri(q)  # [n] a stratified sample of N(0, 1).
    x1 = MEAN_1 + (STD_1 / STD_0) * (x0 - MEAN_0)  # The Brenier map 3 + 2x, the optimal pairing.
    paths = straight_path(x0[None, :], x1[None, :], times[:, None])  # [n+1, n] every trajectory.
    speeds = (paths[1:] - paths[:-1]) / (times[1:] - times[:-1])[:, None]  # [n, n] piecewise speed.
    action = float((speeds ** 2 * (times[1:] - times[:-1])[:, None]).sum(dim=0).mean())
    squared_displacement = float(((x1 - x0) ** 2).mean())
    if loud:
        print("\nStraight lines under the optimal coupling")
        print(f"  action averaged over the particles {action:.4f}  "
              f"average squared displacement {squared_displacement:.4f}")

    mean_half, std_half = displacement_law(0.5)
    grid = torch.linspace(-6.0, 9.0, 15001, dtype=DTYPE)  # A dense grid for the two densities.
    mixture = mixture_density(grid)
    peak = float(grid[int(mixture.argmax())])
    interpolated = normal_density(grid, mean_half, std_half)
    separation = float(0.5 * (interpolated - mixture).abs().sum() * (grid[1] - grid[0]))
    half_distance = gaussian_w2(MEAN_0, STD_0, mean_half, std_half)
    whole_distance = gaussian_w2(MEAN_0, STD_0, MEAN_1, STD_1)
    if loud:
        print(f"\nAt t = 1/2 the displacement interpolation is N({mean_half:.1f}, "
              f"{std_half ** 2:.2f}) with standard deviation {std_half:.1f}")
        print(f"  the mixture of the two ends peaks at x = {peak:.2f} and is not a Gaussian")
        print(f"  total variation between the two candidate densities {separation:.3f}")
        print(f"  W_2(rho_0, rho_1/2) = {half_distance:.3f} = "
              f"{half_distance / whole_distance:.1f} x W_2(mu, nu) = {whole_distance:.3f}")

    mean_speed = float(velocity_field(torch.tensor(3.0 * 0.5, dtype=DTYPE),
                                      torch.tensor(0.5, dtype=DTYPE)))  # A particle at the mean.
    above_speed = float(velocity_field(torch.tensor(3.0 * 0.5 + 1.5, dtype=DTYPE),
                                       torch.tensor(0.5, dtype=DTYPE)))  # One standard deviation up.
    residual = max(abs(continuity_residual(xx, tt))
                   for xx in (-1.0, 0.0, 1.5, 4.0) for tt in (0.1, 0.5, 0.9))
    densities = [action_density(float(t), 2048) for t in torch.linspace(0.0, 1.0, 5, dtype=DTYPE)]
    landing = x0 + 1.0 * velocity_field(x0, torch.zeros((), dtype=DTYPE))  # One Euler step of size 1.
    one_step = float((landing - x1).abs().max())  # The step lands on the Brenier map 3 + 2 x_0.
    if loud:
        print(f"\nv_t(x) = 3 + (x - 3t) / (1 + t)")
        print(f"  at the mean, speed {mean_speed:.0f}; one standard deviation above, "
              f"speed {above_speed:.0f}")
        print(f"  action density at five times {[round(d, 4) for d in densities]}")
        print(f"  largest continuity residual on the test states {residual:.2e}")

    report = {
        "action of the steady particle": steady,
        "action of the dawdling particle": dawdle,
        "squared displacement of both": 4.0,
        "E||x1-x0||^2, ordered": average_squared_displacement(x, y, ordered),
        "E||x1-x0||^2, independent": average_squared_displacement(x, y, independent),
        "E||x1-x0||^2, crossed": average_squared_displacement(x, y, crossed),
        "action of the straight paths": action,
        "W_2^2 of the Gaussian pair": whole_distance ** 2,
        "rho_1/2 mean, standard deviation": f"{mean_half:.1f}, {std_half:.1f}",
        "peak of the mixture": peak,
        "total variation of the two paths": separation,
        "W_2(rho_0, rho_1/2)": half_distance,
        "W_2(mu, nu)": whole_distance,
        "speed at the mean": mean_speed,
        "speed one deviation above": above_speed,
        "action density, every time": max(densities),
        "continuity equation residual": residual,
        "one Euler step of size 1 minus T": one_step,
    }
    print_report("Transport as motion, Chapter 7 Sections 7.7 to 7.7.5", report)
    save_run(args.out, config=vars(args), report=report)  # Written only with --out.


if __name__ == "__main__":
    main()