Download lecture_6/stochastic_map.py from ChatterjeeLab/CIS6270: direct link, hf CLI and curl.
- Browser
- Download file 11.7 kB
-
https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_6/stochastic_map.py
- Command line
-
hf download hf://ChatterjeeLab/CIS6270/lecture_6/stochastic_map.py
-
curl -L -o stochastic_map.py https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_6/stochastic_map.py
11.7 kB
| """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() | |