File size: 8,952 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
"""MeanFlow, the Eulerian identity written in the average-velocity parameterization.

If we substitute X_{s,t}(x) = x + (t-s) v_{s,t}(x) into the Eulerian identity, we are left with one
equation, v_{s,t}(x) = b(x,s) + (t-s) d/ds v_{s,t}(x), with the correction term a total derivative
along the trajectory. One forward-mode pass along the tangent (b, 1, 0) in the arguments (x, s, t)
computes that derivative without forming a Jacobian, and the target uses the realized velocity
x_1 - x_0 of the interpolant in place of b, so a step costs one forward pass and one derivative.

Chapter 6, Sections 6.13, 6.15 and 6.18 of the notes. Trained on the four Gaussian blobs of
``cis6270.data.gaussian_mixture_2d``. Prints the identity worked by hand on dx/dt = x, where the
average velocity is 1.718x, the instantaneous velocity is x and the correction is 0.718x, and then
one-step, two-step and four-step samples from the trained map.

    python lecture_6/meanflow.py                 # 2000 steps, about twenty seconds
    python lecture_6/meanflow.py --steps 20      # a few seconds, for a first look
"""

# %% 01. Imports and configuration
import math

import torch

from cis6270.data import gaussian_mixture_2d
from cis6270.metrics import mean_log_density, mode_report
from cis6270.nets import TwoTimeMLP, count_parameters
from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train


torch.set_num_threads(1)  # These networks are small, and splitting one matrix product costs more
# than it saves. ``tests/conftest.py`` sets the same thing for the test session, and this line
# acts when a reader runs the script directly.

DIAGONAL_FRACTION = 0.25  # Share of each batch drawn with t = s, where the identity is flow matching.


# %% 02. The average velocity of the scalar flow, in closed form
#
# The exact map of dx/dt = x multiplies the state by e^{t-s}, so the displacement over the elapsed
# time is x (e^{t-s} - 1)/(t-s). On the diagonal the limit is x, or b(x,t).
def scalar_average_velocity(x, s, t):
    """v_{s,t}(x) = x (e^{t-s} - 1)/(t - s) on the flow dx/dt = x."""
    elapsed = t - s
    return x * torch.expm1(elapsed) / elapsed  # expm1 keeps the limit accurate near the diagonal.


# %% 03. The identity, checked on the scalar flow by hand
#
# v_{s,t}(x) = b(x,s) + (t-s) d/ds v_{s,t}(x), with d/ds = partial_s + b(x,s) . grad_x. At s = 0,
# t = 1 the partial derivative is -x, the spatial gradient is e - 1, and the transport term is
# therefore (e-1)x, so the correction is x(e-2) = 0.718x.
def scalar_check(x_value: float = 1.0):
    """Return every term of the identity at s = 0, t = 1, each one computed here."""
    x = torch.tensor(x_value)
    s, t = torch.tensor(0.0), torch.tensor(1.0)
    average = scalar_average_velocity(x, s, t)  # v_{0,1}(x) = x(e-1).
    instant = x  # b(x, 0) = x on this flow.
    partial_s = torch.func.jvp(  # Derivative in the start time alone, with x and t held fixed.
        lambda ss: scalar_average_velocity(x, ss, t), (s,), (torch.ones_like(s),))[1]
    gradient_x = torch.func.jvp(  # Derivative in the state alone, the other half of d/ds.
        lambda xx: scalar_average_velocity(xx, s, t), (x,), (torch.ones_like(x),))[1]
    transport = instant * gradient_x  # b(x,s) . grad_x v, the second piece of the total derivative.
    total = partial_s + transport  # d/ds v_{s,t}(x) = x(e - 2).
    return {
        "v_0,1(1)": float(average),
        "b(1, 0)": float(instant),
        "partial_s v": float(partial_s),
        "grad_x v_0,1": float(gradient_x),
        "d/ds v_0,1": float(total),
        "identity gap": float((average - (instant + (t - s) * total)).abs()),
        "one map evaluation": float(x + (t - s) * average),
        "one Euler step": float(x + (t - s) * instant),
        "e": math.e,
    }


# %% 04. The Gaussian flow, where the correction vanishes
#
# A linear interpolant between paired Gaussians produces straight trajectories, and on a straight
# trajectory the average velocity equals the initial velocity, so the total derivative is zero.
def gaussian_check(x_value: float = 0.7):
    """v_{0,t}(x) = 2 + 2x for every end time, and b(x,0) is the same number."""
    x = torch.tensor(x_value)
    average = torch.stack([  # Three end times, all giving the same average velocity.
        (2.0 * t + (1.0 + 2.0 * t) * x - x) / t for t in (0.25, 0.5, 1.0)])
    instant = 2.0 + 2.0 * (x - 0.0) / 1.0  # b(x, 0) on the Gaussian flow.
    return {"v_0,t(0.7) spread": float(average.max() - average.min()),
            "v_0,1(0.7)": float(average[-1]), "b(0.7, 0)": float(instant)}


# %% 05. The MeanFlow target, by one forward-mode pass
#
# The tangent (x_1 - x_0, 1, 0) in the arguments (x, s, t) contracts the full Jacobian of v into
# the total derivative along the trajectory: the realized velocity in the state slot, one in the
# start-time slot, and zero in the end-time slot because the end time is held fixed.
def mean_flow_target(model, xs, s, t, realized):
    """b(x,s) + (t-s) d/ds v_{s,t}(x), detached, the right-hand side of the identity."""
    f = lambda xx, ss: model(xx, ss, t)
    _, total = torch.func.jvp(f, (xs, s), (realized, torch.ones_like(s)))  # [B, d] d/ds v.
    return (realized + (t - s)[:, None] * total).detach()  # [B, d] the target, held fixed.


def make_loss(model, data, batch_size, device):
    """Draw a coupling, draw times from the triangle, and regress v onto its own identity."""

    def loss_fn(step: int) -> torch.Tensor:
        index = torch.randint(0, len(data), (batch_size,), device=device)
        x1 = data[index]  # [B, 2] the data draw.
        x0 = torch.randn_like(x1)  # [B, 2] the source draw.
        s = torch.rand(batch_size, device=device)  # [B] start time.
        t = s + (1.0 - s) * torch.rand(batch_size, device=device)  # [B] end time, s <= t <= 1.
        diagonal = torch.rand(batch_size, device=device) < DIAGONAL_FRACTION  # [B] which rows anchor.
        t = torch.where(diagonal, s, t)  # On those rows the correction term has a factor of zero.
        xs = (1 - s[:, None]) * x0 + s[:, None] * x1  # [B, 2] the interpolant I_s.
        realized = x1 - x0  # [B, 2] the derivative of the interpolant, available in closed form.
        target = mean_flow_target(model, xs, s, t, realized)
        return ((model(xs, s, t) - target) ** 2).mean()

    return loss_fn


# %% 06. Sampling, at any step count the reader chooses
@torch.no_grad()
def sample(model, x0, steps: int):
    """Cross [0, 1] in equal pieces, one evaluation of the map per piece."""
    x = x0
    grid = torch.linspace(0.0, 1.0, steps + 1, device=x0.device)  # [steps+1] the time grid.
    for s, t in zip(grid[:-1], grid[1:]):
        x = model.flow_map(x, s.expand(len(x)), t.expand(len(x)))  # X_{s,t}(x).
    return x  # [N, 2] generated points.


# %% 07. The report
def main() -> None:
    parser = common_parser(__doc__.split("\n")[0])
    parser.set_defaults(steps=2000, batch_size=128, samples=512)
    args = parser.parse_args()
    set_seed(args.seed)
    device = pick_device(args.device)
    data = gaussian_mixture_2d(num_points=2048, seed=args.seed).to(device)  # [2048, 2].

    report = scalar_check()
    report.update(gaussian_check())
    if not args.quiet:
        print(f"on dx/dt = x at s=0, t=1: v = {report['v_0,1(1)']:.6f} = b {report['b(1, 0)']:.6f}"
              f" + correction {report['d/ds v_0,1']:.6f}")
        print(f"one map evaluation {report['one map evaluation']:.6f} against one Euler step "
              f"{report['one Euler step']:.6f}, exact e = {math.e:.6f}")

    model = TwoTimeMLP(dim=2, width=args.width).to(device)
    if not args.quiet:
        print(f"\ntraining MeanFlow  parameters={count_parameters(model):,}")
    losses = train(model, make_loss(model, data, args.batch_size, device),
                   steps=args.steps, lr=args.lr, quiet=args.quiet)

    torch.manual_seed(args.seed + 1)
    x0 = torch.randn(args.samples, 2, device=device)  # [N, 2] the source points every run shares.
    samples: list[str] = []
    for steps in (1, 2, 4):
        points = sample(model, x0, steps)
        stats = mode_report(points)
        report[f"{steps}-step nearest distance"] = stats["nearest_distance"]
        report[f"{steps}-step mode entropy"] = stats["mode_entropy"]
        report[f"{steps}-step log density"] = mean_log_density(points.cpu())
        samples.append(f"{steps} step(s): nearest {stats['nearest_distance']:.3f}  "
                       f"modes {stats['modes_found']}  entropy {stats['mode_entropy']:.3f}")
    one, four = sample(model, x0, 1), sample(model, x0, 4)
    report["one step against four"] = float((one - four).norm(dim=1).mean())
    report["final loss"] = sum(losses[-20:]) / len(losses[-20:])

    print_report("MeanFlow, Chapter 6", report, samples)
    save_run(args.out, model=model, config=vars(args), report=report, losses=losses, samples=samples)


if __name__ == "__main__":
    main()