File size: 8,907 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 | """Shortcut models, the composition identity indexed by step size.
The second input of the network is the length of the step and not the time at which it ends, and we
enforce the identity only between a step and the step twice as long. One doubling constraint
suffices because the ladder chains. With a finest step of 1/128 and seven doublings, the one-step
map at level seven rests on seven relations and one regression against the data.
Chapter 6, Section 6.20 of the notes. Trained on the four Gaussian blobs of
``cis6270.data.gaussian_mixture_2d``. Prints the doubling worked on dx/dt = x, where two steps of
0.25 average to 1.297443 and the direct step of 0.5 returns the same number, then the dyadic ladder
and the trained model's doubling residual at every rung.
python lecture_6/shortcut.py # 2000 steps, about twenty seconds
python lecture_6/shortcut.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.
FINEST = 1.0 / 128.0 # d_0, the finest step of the ladder.
LEVELS = 7 # log2(128) doublings, so the step sizes are d_k = 2^k d_0 for k = 0 to 7.
ZERO_FRACTION = 0.75 # Three quarters of each batch sits at step size zero.
# %% 02. The network in step-size coordinates
#
# The substitution s_theta(x, t, d) = v_{t, t+d}(x) turns the published network into the two-time
# average velocity of this chapter, so the same weights serve both readings.
def shortcut(model, x, t, d):
"""s_theta(x, t, d), the average velocity over the step of length d starting at time t."""
return model(x, t, t + d) # [B, 2] = v_{t, t+d}(x).
def take_step(model, x, t, d):
"""One step of length d, the flow map X_{t, t+d} in these coordinates."""
return x + d[:, None] * shortcut(model, x, t, d) # [B, 2].
# %% 03. The doubling identity, worked on the scalar flow
#
# s(x, t, 2d) = half s(x, t, d) + half s(x', t+d, d) with x' = x + d s(x, t, d). On dx/dt = x the
# average velocity is x (e^d - 1)/d, and the identity holds at every step length at once.
def scalar_doubling(x_value: float = 1.0, d: float = 0.25):
"""Every term of the doubling identity on dx/dt = x, each one computed here."""
first = x_value * math.expm1(d) / d # s(1, 0, 0.25).
reached = x_value + d * first # x', or e^{0.25} to six figures.
second = reached * math.expm1(d) / d # s(x', 0.25, 0.25), faster because the state has grown.
doubled = x_value * math.expm1(2.0 * d) / (2.0 * d) # s(1, 0, 0.5), computed directly.
return {
"s(1, 0, d)": first,
"state after the first step": reached,
"s(x', d, d)": second,
"average of the two": 0.5 * (first + second),
"s(1, 0, 2d) direct": doubled,
"doubling gap": abs(0.5 * (first + second) - doubled),
"state after the doubled step": x_value + 2.0 * d * doubled,
"exp(2d)": math.exp(2.0 * d),
}
# %% 04. The dyadic ladder
#
# Eight step sizes from 1/128 up to 1, each training pair tying level k to level k+1. A state at
# level zero is already trained against the diagonal by the anchor, so the correctness of the
# one-step map rests on a chain of seven relations and one regression.
def ladder():
"""The step sizes d_k = 2^k d_0, from the finest to the whole interval."""
return [FINEST * 2.0 ** k for k in range(LEVELS + 1)] # [1/128, ..., 1.0].
# %% 05. The loss
#
# At step size zero the target is the flow matching target of Chapter 2, and above it the batch
# draws a level of the ladder, forms the two short steps and regresses the doubled step onto their
# average, with the target detached. The chapter puts three quarters of the batch on the zero step
# and the rest on the doubling. Here we evaluate both terms on the whole batch and weight them in
# that proportion. The expectation is the same at one network call more per step.
def make_loss(model, data, batch_size, device):
sizes = torch.tensor(ladder()[:LEVELS], device=device) # Levels whose doubling still fits in [0,1].
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.
t = torch.rand(batch_size, device=device) # [B] a time for the zero-step rows.
xt = (1 - t[:, None]) * x0 + t[:, None] * x1 # [B, 2] the interpolant.
zero = torch.zeros(batch_size, device=device)
anchor = ((shortcut(model, xt, t, zero) - (x1 - x0)) ** 2).mean() # Step size zero.
d = sizes[torch.randint(0, LEVELS, (batch_size,), device=device)] # [B] one rung each.
start = (1.0 - 2.0 * d) * torch.rand(batch_size, device=device) # [B] t with t + 2d <= 1.
xs = (1 - start[:, None]) * x0 + start[:, None] * x1 # [B, 2] the interpolant at t.
with torch.no_grad(): # The two legs are frozen; only the doubled step is corrected.
first = shortcut(model, xs, start, d)
reached = xs + d[:, None] * first
second = shortcut(model, reached, start + d, d)
target = 0.5 * (first + second)
doubled = ((shortcut(model, xs, start, 2.0 * d) - target) ** 2).mean()
return ZERO_FRACTION * anchor + (1.0 - ZERO_FRACTION) * doubled
return loss_fn
# %% 06. Sampling, and the residual at every rung
@torch.no_grad()
def sample(model, x0, steps: int):
"""Cross [0, 1] in equal steps of length 1/steps, one network evaluation per step."""
x, d = x0, torch.full((len(x0),), 1.0 / steps, device=x0.device)
for k in range(steps):
t = torch.full((len(x0),), k / steps, device=x0.device)
x = take_step(model, x, t, d)
return x # [N, 2].
@torch.no_grad()
def rung_residuals(model, x0):
"""The squared doubling gap at each level of the ladder, measured on source points."""
out = {}
for k, d in enumerate(ladder()[:LEVELS]):
step = torch.full((len(x0),), d, device=x0.device)
start = torch.zeros(len(x0), device=x0.device)
first = shortcut(model, x0, start, step)
second = shortcut(model, x0 + step[:, None] * first, start + step, step)
gap = shortcut(model, x0, start, 2.0 * step) - 0.5 * (first + second)
out[f"rung {k}: d = 1/{int(round(1.0 / d))}"] = float((gap ** 2).mean())
return out
# %% 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_doubling()
report["ladder levels"] = LEVELS
report["finest step d_0"] = FINEST
if not args.quiet:
print(f"on dx/dt = x: two steps of 0.25 average to {report['average of the two']:.6f}, "
f"one step of 0.5 returns {report['s(1, 0, 2d) direct']:.6f}")
print(f"ladder {[round(d, 5) for d in ladder()]}")
model = TwoTimeMLP(dim=2, width=args.width).to(device)
if not args.quiet:
print(f"\ntraining the shortcut model 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 shared source points.
report.update(rung_residuals(model, x0))
samples: list[str] = []
for steps in (1, 2, 4, 8):
points = sample(model, x0, steps)
stats = mode_report(points)
report[f"{steps}-step nearest distance"] = stats["nearest_distance"]
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}")
report["one step against eight"] = float(
(sample(model, x0, 1) - sample(model, x0, 8)).norm(dim=1).mean())
report["final loss"] = sum(losses[-20:]) / len(losses[-20:])
print_report("Shortcut models, Chapter 6", report, samples)
save_run(args.out, model=model, config=vars(args), report=report, losses=losses, samples=samples)
if __name__ == "__main__":
main()
|