File size: 10,028 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
"""The flow map of an ordinary differential equation, with no learning anywhere.

A flow map X_{s,t}(x) returns the state at time t of the trajectory that passes through x at time
s, so one evaluation replaces the N Euler steps of Chapter 2. Here we integrate a velocity we
already know, the Gaussian flow of the chapter, and check against it every property the rest of
the lecture trains for: the three axioms, the inverse, the Jacobian and its differential equation,
Liouville's formula, the transport of a density, and the generator.

Chapter 6, Sections 6.3 to 6.12 of the notes. Runs on the scalar Gaussian flow and on a batch from
``cis6270.data.gaussian_mixture_2d``. Prints the four numbers of the chapter's listing, 4.100000
for the map, the cocycle agreeing with it, 0.700000 for the inverse and a Jacobian determinant of
3.000000, together with log 3, the density ratio and the log likelihood.

    python lecture_6/flow_map.py                 # 200 Runge-Kutta substeps per crossing
    python lecture_6/flow_map.py --steps 20      # 20 substeps, a second, and the same report
"""

# %% 01. Imports and configuration
import math

import torch
from torch.func import jacrev  # Differentiates the whole integration in its starting point.

from cis6270.data import GAUSSIAN_STD, gaussian_mixture_2d
from cis6270.runner import common_parser, print_report, save_run, set_seed

torch.set_num_threads(1)  # The integration is a scalar loop over a small batch, so one thread is
# the fastest here. ``tests/conftest.py`` sets the same thing for the test session, and this line
# acts when a reader runs the script directly.
torch.set_default_dtype(torch.float64)  # We check the axioms below the float32 noise floor.


SUBSTEPS = 200  # n, the Runge-Kutta substeps per crossing.
START = 0.7  # The state x at which the chapter evaluates every check.


# %% 02. The velocity field of the Gaussian flow
#
# Source N(0,1) and data N(2,9), paired by x_1 = 2 + 3 x_0 and joined by a straight line. The
# velocity is a translation at speed 2 plus an expansion whose strength 2/(1+2t) decays as the
# cloud widens.
def b(x, t):  # velocity field
    return 2.0 + 2.0 * (x - 2.0 * t) / (1.0 + 2.0 * t)  # Section 6.9, b(x,t).


def exact_map(x, s, t):
    """X_{s,t}(x) = 2t + (1+2t)(x - 2s)/(1 + 2s), the closed form the integrator has to reproduce."""
    return 2.0 * t + (1.0 + 2.0 * t) * (x - 2.0 * s) / (1.0 + 2.0 * s)  # [B] or scalar.


# %% 03. The flow map by fourth-order Runge-Kutta
#
# Four velocity evaluations per substep, at its start, twice at its midpoint and at its end,
# combined with the weights 1, 2, 2, 1 over six. The step h is negative when t is below s, so the
# same function integrates backward with no change.
def flow(x, s, t, n=SUBSTEPS):  # X_{s,t}(x) by RK4
    h = (t - s) / n
    for k in range(n):
        u = s + k * h
        k1 = b(x, u)
        k2 = b(x + 0.5 * h * k1, u + 0.5 * h)
        k3 = b(x + 0.5 * h * k2, u + 0.5 * h)
        k4 = b(x + h * k3, u + h)
        x = x + (h / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
    return x


# %% 04. The three axioms, checked numerically
#
# Identity, cocycle and tangent characterize the map (Proposition 6.1). The first holds by
# definition, the second by uniqueness of the trajectory, the third by the defining equation.
def check_axioms(x, n):
    """Return the residual of each axiom at the state x, all three zero for the true map."""
    identity = flow(x, 0.5, 0.5, n) - x  # X_{t,t} = id.
    cocycle = flow(flow(x, 0.0, 0.5, n), 0.5, 1.0, n) - flow(x, 0.0, 1.0, n)  # X_{t,u} o X_{s,t}.
    h = 1e-6  # One-sided difference quotient at the diagonal.
    tangent = (flow(x, 0.5, 0.5 + h, n) - x) / h - b(x, 0.5)  # d/dt X at t = s equals b(x,s).
    return float(identity.abs()), float(cocycle.abs()), float(tangent.abs())


# %% 05. The Jacobian and the equation it satisfies
#
# dJ/dt = grad b(X_{s,t}(x), t) J, starting from J_{s,s} = I. In one dimension grad b is the single
# derivative 2/(1+2t), and the solution from zero is J_{0,t} = 1 + 2t.
def jacobian_by_ode(x, s, t, n):
    """Integrate the state and its Jacobian together, with one Euler step each on the same grid."""
    jacobian = torch.ones_like(x)  # J_{s,s} = I, the identity axiom differentiated in x.
    h = (t - s) / n
    for k in range(n):
        u = s + k * h
        grad_b = 2.0 / (1.0 + 2.0 * u)  # d b / d x at time u, the divergence in one dimension.
        jacobian = jacobian + h * grad_b * jacobian  # Linear in J, with the coefficient frozen.
        x = x + h * b(x, u)  # The trajectory the coefficient is read along.
    return jacobian


def liouville_log_det(s, t, n):
    """Integrate d/dt log det J = div b, on this flow the number log 3 over [0, 1]."""
    h = (t - s) / n
    grid = s + h * (torch.arange(n) + 0.5)  # Midpoints of the substeps.
    return float((2.0 / (1.0 + 2.0 * grid)).sum() * h)  # Riemann sum of the divergence.


# %% 06. Transporting a density along the flow
#
# p_t(X_{s,t}(x)) |det J_{s,t}(x)| = p_s(x). The flow stretches every length by 3 over the unit
# interval, so it divides every density by 3.
def normal_density(x, mean, std):
    """The scalar Gaussian density, used at both ends of the transport statement."""
    return math.exp(-((x - mean) ** 2) / (2.0 * std ** 2)) / (std * math.sqrt(2.0 * math.pi))


def mixture_log_density_2d(points, scale, shift):
    """Log density of the four-Gaussian mixture after the affine map y = shift + scale * x.

    The pushforward of a Gaussian mixture under an affine map is a Gaussian mixture with the
    centers and the widths carried through. The transported density is then a closed form.
    """
    from cis6270.data import GAUSSIAN_CENTERS

    centers = shift + scale * GAUSSIAN_CENTERS.to(points)  # [4, 2] transported centers.
    std = scale * GAUSSIAN_STD  # Every component widens by the same factor.
    squared = ((points[:, None, :] - centers) ** 2).sum(dim=-1)  # [N, 4].
    component = -0.5 * squared / std ** 2 - math.log(2.0 * math.pi * std ** 2)  # [N, 4].
    return torch.logsumexp(component + math.log(0.25), dim=-1)  # [N] mixture log density.


# %% 07. The generator of the flow
#
# (L_t f)(x) = b(x,t) . grad f(x), and the rate of change of f along a trajectory is the generator
# applied to f and read at the moving point.
def generator(f, grad_f, x, t):
    """(L_t f)(x) for a scalar test function, in one dimension b times the derivative."""
    return b(x, t) * grad_f(x)  # Section 6.11, the generator.


# %% 08. The report
def main() -> None:
    parser = common_parser(__doc__.split("\n")[0])
    parser.set_defaults(steps=SUBSTEPS, samples=512)
    args = parser.parse_args()  # --steps is the substep count n here, since nothing is trained.
    set_seed(args.seed)
    n = max(args.steps, 4)  # A floor of four substeps, since the weights are fourth order.

    # %% 09. The chapter's four lines
    x = torch.tensor(START)  # The single starting point every check below uses.
    one_crossing = flow(x, 0.0, 1.0, n)  # 2 + 3x = 4.1.
    two_crossings = flow(flow(x, 0.0, 0.5, n), 0.5, 1.0, n)  # The cocycle.
    there_and_back = flow(flow(x, 0.0, 1.0, n), 1.0, 0.0, n)  # The inverse.
    det_jacobian = jacrev(lambda z: flow(z, 0.0, 1.0, n))(x)  # det J = 3.
    if not args.quiet:
        print(f"X_0,1({START})      = {float(one_crossing):.6f}   closed form "
              f"{float(exact_map(x, 0.0, 1.0)):.6f}")
        print(f"cocycle through 0.5 = {float(two_crossings):.6f}")
        print(f"inverse X_1,0 o X_0,1 = {float(there_and_back):.6f}")
        print(f"det J_0,1           = {float(det_jacobian):.6f}")

    identity, cocycle, tangent = check_axioms(x, n)

    # %% 10. The Jacobian two ways, and Liouville's formula
    jacobian_ode = jacobian_by_ode(x, 0.0, 1.0, 4000)  # One Euler solve of dJ/dt = grad b . J.
    log_det = liouville_log_det(0.0, 1.0, 4000)  # Integral of the divergence over [0, 1].

    # %% 11. Transport, in one dimension and in two
    image = float(one_crossing)  # X_{0,1}(0.7) = 4.1.
    p_source = normal_density(START, 0.0, 1.0)  # p_0(0.7).
    p_data = normal_density(image, 2.0, 3.0)  # p_1(4.1).
    log_likelihood = math.log(p_source) - log_det  # Section 6.10, with the divergence integral.

    points = gaussian_mixture_2d(num_points=args.samples, seed=args.seed)  # [N, 2] source cloud.
    moved = exact_map(points, 0.0, 1.0)  # The same affine flow applied to both coordinates.
    log_p0 = mixture_log_density_2d(points, 1.0, 0.0)  # [N] density before transport.
    log_p1 = mixture_log_density_2d(moved, 3.0, 2.0)  # [N] density of the transported mixture.
    transport_gap = float((log_p1 + 2.0 * math.log(3.0) - log_p0).abs().max())  # det J = 3^2 = 9.

    # %% 12. The generator along one trajectory
    f, grad_f = (lambda z: z ** 2), (lambda z: 2.0 * z)  # A test function and its derivative.
    h = 1e-5
    along = (f(flow(x, 0.0, 0.5 + h, n)) - f(flow(x, 0.0, 0.5 - h, n))) / (2.0 * h)  # d/dt f(X).
    generator_gap = abs(float(along) - float(generator(f, grad_f, flow(x, 0.0, 0.5, n), 0.5)))

    report = {
        "X_0,1(0.7)": float(one_crossing),
        "cocycle through u=0.5": float(two_crossings),
        "inverse back to the start": float(there_and_back),
        "det J_0,1 by jacrev": float(det_jacobian),
        "det J_0,1 by its own ODE": float(jacobian_ode),
        "log det J_0,1 by Liouville": log_det,
        "identity axiom residual": identity,
        "cocycle axiom residual": cocycle,
        "tangent axiom residual": tangent,
        "p_0(0.7)": p_source,
        "p_1(4.1)": p_data,
        "density ratio p_0/p_1": p_source / p_data,
        "log p_1(4.1) by transport": log_likelihood,
        "log p_1(4.1) directly": math.log(p_data),
        "2-D transport gap": transport_gap,
        "generator gap": generator_gap,
        "substeps n": n,
    }
    print_report("Flow map of the Gaussian flow, Chapter 6", report)
    save_run(args.out, config=vars(args), report=report)


if __name__ == "__main__":
    main()