File size: 11,655 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 | """Strong stochastic flow maps, where the driving path is an argument of the map.
A map that matches only the transition kernel draws fresh noise on every leg, so one full step and
two half steps follow different trajectories and their composition residual has a floor at twice
the variance of the interval. A strong map takes the path itself, projected onto the first few
shifted Legendre polynomials. The Chen relations build the coefficients of a long interval from the
coefficients of its halves, so one realization drives all three legs of the cocycle.
Chapter 6, Section 6.31 of the notes. Runs on the linear equation dX = X dt + sigma dW
and on a cloud of starting states from ``cis6270.data.gaussian_mixture_2d``. Prints the weak
map's floor of 2K_1 = 6.389, the Legendre variances 1, 1/3 and 1/5, the Chen fusion that turns
four half-interval draws into (0.50, -0.20), and the pathwise error against the coefficient count.
python lecture_6/stochastic_map.py # 400,000 Monte Carlo draws, a few seconds
python lecture_6/stochastic_map.py --steps 20 # the floor of 1,000 draws, for a first look
"""
# %% 01. Imports and configuration
import math
import torch
from cis6270.data import gaussian_mixture_2d
from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed
torch.set_num_threads(1) # The work here is elementwise, and one thread keeps the timing stable.
# ``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) # The checks below sit well under the float32 noise floor.
SIGMA = 1.0 # The noise level of dX = X dt + sigma dW.
ORDER = 3 # Coefficients carried per interval, the number used in practice.
FINEST = 32 # Subintervals we draw the path on before the Chen relations fuse it upward.
# %% 02. The transition kernel, and the weak map built from it
#
# Start at x at time s. The state at t is then e^{t-s} x plus a centered Gaussian whose variance
# is K_{t-s}. A weak map reproduces that law on every interval with one fresh draw.
def kernel_variance(d):
"""K_d = sigma^2 (e^{2d} - 1)/2, the variance the equation accumulates over a length d."""
return 0.5 * SIGMA ** 2 * (math.exp(2.0 * d) - 1.0)
def weak_step(x, d, noise):
"""The weak map, a function of a state and one fresh standard normal draw."""
return math.exp(d) * x + math.sqrt(kernel_variance(d)) * noise
# %% 03. The cost of matching only the transition kernel
#
# The variances compose correctly, so the map is exact at the level of distributions. At the level
# of samples the three draws are independent, so the squared gap between one full step and two
# half steps averages to twice the full-interval variance however well the map is trained.
def weak_floor(x: float = 0.3, u: float = 0.5, t: float = 1.0, draws: int = 400_000, seed: int = 0):
"""The worked comparison of Section 6.31, and a Monte Carlo average of the floor."""
full = weak_step(x, t, 0.8) # One step with the chapter's third draw.
half = weak_step(weak_step(x, u, 0.5), t - u, -1.2) # Two legs with the first two draws.
carried = math.exp(2.0 * (t - u)) * kernel_variance(u) + kernel_variance(t - u)
matching = (math.exp(t - u) * math.sqrt(kernel_variance(u)) * 0.5
- math.sqrt(kernel_variance(t - u)) * 1.2) / math.sqrt(kernel_variance(t))
generator = torch.Generator().manual_seed(seed)
e1, e2, e3 = (torch.randn(draws, generator=generator) for _ in range(3))
gap = (math.sqrt(kernel_variance(t)) * e3
- math.exp(t - u) * math.sqrt(kernel_variance(u)) * e1
- math.sqrt(kernel_variance(t - u)) * e2) # [draws] the difference of the two routes.
return {
"K_1": kernel_variance(t), "sqrt K_1": math.sqrt(kernel_variance(t)),
"sqrt K_0.5": math.sqrt(kernel_variance(u)),
"one full step": full, "two half steps": half, "gap": full - half,
"squared gap": (full - half) ** 2,
"carried variance equals K_1": carried, "floor 2 K_1": 2.0 * kernel_variance(t),
"the draw that would close it": matching,
"Monte Carlo floor": float((gap ** 2).mean()),
}
# %% 04. The path as an input, through the shifted Legendre polynomials
#
# I^{(n)}_{s,t} integrates the n-th shifted Legendre polynomial of the normalized time against the
# increment. The zeroth coefficient is the plain increment, the first separates a path that did
# its travelling early from one that did it late, and distinct coefficients are independent.
def legendre(n: int, r):
"""The n-th shifted Legendre polynomial on [0, 1]."""
return [torch.ones_like(r), 2.0 * r - 1.0, 6.0 * r * r - 6.0 * r + 1.0][n]
def draw_coefficients(shape, length: float, generator):
"""I^{(n)} ~ N(0, (t-s)/(2n+1)), drawn directly, with no path simulated."""
scale = torch.tensor([math.sqrt(length / (2 * n + 1)) for n in range(ORDER)]) # [ORDER].
return torch.randn(*shape, ORDER, generator=generator) * scale # [..., ORDER].
def kernel_coefficients(length: float, nodes: int = 8001):
"""a_n(h) in e^{h(1-r)} = sum_n a_n P_n(r), and it turns the Ito integral into a finite sum."""
r = torch.linspace(0.0, 1.0, nodes)
weight = torch.exp(length * (1.0 - r)) # The kernel e^{t-u} written in normalized time.
return torch.stack([(2 * n + 1) * torch.trapz(weight * legendre(n, r), r)
for n in range(ORDER)]) # [ORDER].
def strong_step(x, length: float, coefficients):
"""The solution map over one interval, driven by the coefficients of the path on it."""
kernel = kernel_coefficients(length).to(x) # [ORDER].
return math.exp(length) * x + SIGMA * (coefficients * kernel).sum(-1) # Same shape as x.
# %% 05. The Chen relations
#
# A shifted Legendre polynomial of the long interval expands in the shifted Legendre basis of each
# half, so the coefficients fuse by a fixed linear rule with signs alternating in the parity of
# n + m. In the first rule the increments add, and the minus sign in the second is the oddness
# of P_1 about the midpoint.
def chen(first, second):
"""Fuse the coefficients of two equal halves into the coefficients of their union."""
return torch.stack([
first[..., 0] + second[..., 0], # The increments add.
-0.5 * (first[..., 0] - second[..., 0]) + 0.5 * (first[..., 1] + second[..., 1]),
0.25 * (first[..., 2] + second[..., 2]) - 0.75 * (first[..., 1] - second[..., 1]),
], dim=-1)
def fuse_to(pieces, target: int):
"""Halve the number of intervals by Chen fusion until ``target`` intervals remain."""
while pieces.shape[-2] > target:
pieces = chen(pieces[..., 0::2, :], pieces[..., 1::2, :]) # Fuse neighbouring pairs.
return pieces # [..., target, ORDER].
def worked_chen():
"""The chapter's four half-interval draws, fused into the coefficients of the unit interval."""
halves = torch.tensor([[0.80, 0.20, 0.0], [-0.30, 0.50, 0.0]]) # [2, ORDER], the two legs.
fused = chen(halves[0], halves[1])
return {"I0 over [0,1]": float(fused[0]), "I1 over [0,1]": float(fused[1]),
"variance of I1 at h=1": 1.0 / 3.0,
"fused variance at h=1": 0.25 * 0.5 + 0.25 * 0.5 + 0.25 / 6.0 + 0.25 / 6.0}
# %% 06. The pathwise error against the number of coefficients
#
# A fine simulation gives both the exact Ito integral and the coefficients of the same path, so we
# can measure the truncation on the realization it was taken from.
def truncation_error(paths: int, grid: int, seed: int):
"""Mean absolute pathwise error of a one-step map reading 1, 2 and 3 coefficients."""
generator = torch.Generator().manual_seed(seed)
step = 1.0 / grid
time = torch.arange(grid) * step # [grid] left endpoints of the substeps.
increments = torch.randn(paths, grid, generator=generator) * math.sqrt(step) # [P, grid].
exact = (torch.exp(1.0 - time)[None, :] * increments).sum(1) # [P] the Ito integral.
measured = torch.stack([(legendre(n, time)[None, :] * increments).sum(1)
for n in range(ORDER)], dim=1) # [P, ORDER].
kernel = kernel_coefficients(1.0) # [ORDER].
out = {f"coefficient variance I{n}": float(measured[:, n].var()) for n in range(ORDER)}
for keep in range(1, ORDER + 1):
approximate = (measured[:, :keep] * kernel[:keep]).sum(1) # [P].
out[f"pathwise error with {keep}"] = float((exact - approximate).abs().mean())
return out
# %% 07. One path, several step counts
#
# A strong map crosses one trajectory of one realization in any number of hops, so its terminal
# state barely moves with the step count. A weak map redraws at every change of the step count,
# so its terminal state moves by the floor of Section 6.31.
def cross(x, coefficients, steps: int, weak: bool, generator=None):
"""Cross [0, 1] in ``steps`` equal hops, either along the fused path or with fresh draws."""
pieces = fuse_to(coefficients, steps) # [..., steps, ORDER] on this grid.
length = 1.0 / steps
for k in range(steps):
if weak:
noise = torch.randn(x.shape, generator=generator) # A fresh draw for every leg.
x = weak_step(x, length, noise)
else:
x = strong_step(x, length, pieces[..., k, :])
return x
# %% 08. The report
def main() -> None:
parser = common_parser(__doc__.split("\n")[0])
parser.set_defaults(steps=400_000, samples=512)
args = parser.parse_args() # --steps is the Monte Carlo draw count here, since nothing trains.
set_seed(args.seed)
pick_device(args.device) # Every number here is double precision, and not every
# accelerator carries it, so the work stays on the CPU whatever --device names.
report = weak_floor(draws=max(args.steps, 1000), seed=args.seed)
report.update(worked_chen())
report.update(truncation_error(paths=1024, grid=4096, seed=args.seed))
if not args.quiet:
print(f"weak floor 2K_1 = {report['floor 2 K_1']:.3f}, Monte Carlo "
f"{report['Monte Carlo floor']:.3f}")
print(f"pathwise error {report['pathwise error with 1']:.4f} with the increment alone "
f"against {report['pathwise error with 3']:.6f} with three coefficients")
# %% 09. The same cloud at four step counts
points = gaussian_mixture_2d(num_points=args.samples, seed=args.seed).double() # [N, 2].
generator = torch.Generator().manual_seed(args.seed + 1)
path = draw_coefficients((args.samples, 2, FINEST), 1.0 / FINEST, generator) # [N, 2, F, 3].
reference = cross(points, path, FINEST, weak=False) # The finest crossing, as the yardstick.
samples: list[str] = []
for steps in (2, 4, 8, 16): # Short of FINEST, the yardstick against itself.
strong = cross(points, path, steps, weak=False) # One realization, several hop counts.
weak_generator = torch.Generator().manual_seed(args.seed + 2)
weak = cross(points, path, steps, weak=True, generator=weak_generator)
report[f"{steps} hops: strong spread"] = float((strong - reference).norm(dim=1).mean())
report[f"{steps} hops: weak spread"] = float((weak - reference).norm(dim=1).mean())
samples.append(f"{steps:2d} hops strong {float((strong - reference).norm(dim=1).mean()):.6f}"
f" weak {float((weak - reference).norm(dim=1).mean()):.3f}")
print_report("Strong stochastic flow maps, Chapter 6", report, samples)
save_run(args.out, config=vars(args), report=report, samples=samples)
if __name__ == "__main__":
main()
|