Files
optimiz-rs/examples/animate_mckean_vlasov.py
T
ThotDjehuty 24556f51d7 release(v2.0.0): finalize PyPI metadata, README rewrite, v2 benchmark + McKean-Vlasov animation
- Restore correct PyPI distribution name 'optimiz-rs' (continuity with v1.0.x).
  Rust crate stays 'optimiz-rs'; Python module is 'optimizr'.
- python/optimizr/__init__.py:
  * Bump __version__ from stale '0.2.0' to '2.0.0'.
  * Eagerly bind every v2 primitive from _core (so dir(optimizr), IDE
    auto-complete and 'from optimizr import X' all work without relying on
    the lazy __getattr__ fallback).
  * Extend __all__ with 38 new v2 entries.
- README.md: full v2 features section grouped by domain (rough volatility,
  BSDE/PDE, stochastic control, mean-field, topology/graphs/signatures,
  risk/robust inference, point processes, Kalman). Embedded
  examples/mckean_vlasov.gif at the top. Added v2 benchmark table.
- examples/benchmark_v2.py: honest benchmark vs pure-Python/NumPy
  references on intrinsically loopy workloads. Best-of-3, single-thread,
  Apple M2: HMM 67.7x, DE 13.9x, signatures 11.2x, Hawkes 3.3x, MCMC 1.7x.
- examples/animate_mckean_vlasov.py + examples/mckean_vlasov.gif (5MB):
  cinematic 800-particle mean-reverting McKean-Vlasov flow animation
  using optimizr.mean_reverting_mckean_vlasov.
- tests/test_v2_api.py already in place: 20/20 pass.
2026-05-14 21:54:49 +02:00

154 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Cool animation: mean-reverting McKean-Vlasov particle system.
Uses ``optimizr.mean_reverting_mckean_vlasov`` to simulate N
interacting particles whose drift pulls each toward the empirical
mean of the population, perturbed by a Brownian noise. We render
* a time-evolving particle scatter (top panel)
* the rolling empirical density estimated via a Gaussian KDE (bottom panel)
and save the result to ``examples/mckean_vlasov.gif``.
Run with:
python examples/animate_mckean_vlasov.py
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import animation
from matplotlib.colors import LinearSegmentedColormap
import optimizr as opt
# ---------------------------------------------------------------------------
# Parameters -- two well-separated initial clouds that fuse over time
# ---------------------------------------------------------------------------
N_PART = 800
N_STEPS = 400
T_HORIZON = 4.0
THETA = 0.6 # mean-reversion strength toward empirical mean
SIGMA = 0.25 # Brownian noise amplitude
SEED = 7
rng = np.random.default_rng(SEED)
left = rng.normal(-2.0, 0.35, N_PART // 2)
right = rng.normal(+2.0, 0.35, N_PART // 2)
initial = np.concatenate([left, right])
print(f"Simulating N={N_PART} particles for {N_STEPS} steps...")
out = opt.mean_reverting_mckean_vlasov(
initial=initial.tolist(),
theta=THETA,
sigma=SIGMA,
n_steps=N_STEPS,
t_horizon=T_HORIZON,
seed=SEED,
)
paths = np.asarray(out["paths_flat"]).reshape(N_STEPS + 1, N_PART)
times = np.asarray(out["time_grid"])
# ---------------------------------------------------------------------------
# Density grid via Gaussian KDE (vectorised)
# ---------------------------------------------------------------------------
x_grid = np.linspace(-3.5, 3.5, 240)
bw = 0.18
density = np.empty((N_STEPS + 1, x_grid.size))
norm = 1.0 / (N_PART * bw * np.sqrt(2 * np.pi))
for k in range(N_STEPS + 1):
diffs = (x_grid[:, None] - paths[k][None, :]) / bw
density[k] = norm * np.exp(-0.5 * diffs * diffs).sum(axis=1)
# ---------------------------------------------------------------------------
# Figure setup -- dark, cinematic look
# ---------------------------------------------------------------------------
plt.rcParams.update({
"axes.facecolor": "#0b1020",
"figure.facecolor": "#0b1020",
"axes.edgecolor": "#3a4a72",
"axes.labelcolor": "#dbe7ff",
"xtick.color": "#9eb1d8",
"ytick.color": "#9eb1d8",
"text.color": "#dbe7ff",
"axes.grid": True,
"grid.color": "#1e2a47",
"grid.linestyle": "--",
"grid.alpha": 0.5,
})
fig, (ax_top, ax_bot) = plt.subplots(
2, 1, figsize=(7, 4.5), gridspec_kw={"height_ratios": [3, 2]}, dpi=80,
)
# Cool blue-orange diverging colormap for particles by initial position
norm_color = (initial - initial.min()) / (initial.max() - initial.min())
cmap = LinearSegmentedColormap.from_list("cool_warm", ["#39d2ff", "#ff7847"])
colors = cmap(norm_color)
scat = ax_top.scatter(
paths[0], np.random.uniform(0, 1, N_PART),
c=colors, s=10, alpha=0.85, edgecolors="none",
)
ax_top.set_xlim(-3.5, 3.5)
ax_top.set_ylim(0, 1)
ax_top.set_yticks([])
ax_top.set_title(
"McKeanVlasov mean-reverting flow\n"
f"$dX_t = \\theta(\\bar m_t - X_t)\\,dt + \\sigma\\,dW_t$"
f" ($N = {N_PART}$, $\\theta = {THETA}$, $\\sigma = {SIGMA}$)",
fontsize=11, color="#e9efff", pad=12,
)
mean_line = ax_top.axvline(initial.mean(), color="#ffd166", lw=1.2, ls="--",
label="empirical mean $\\bar m_t$")
ax_top.legend(loc="upper right", framealpha=0.2, edgecolor="#3a4a72")
(line,) = ax_bot.plot(x_grid, density[0], color="#39d2ff", lw=2)
fill = ax_bot.fill_between(x_grid, density[0], color="#39d2ff", alpha=0.25)
ax_bot.set_xlim(-3.5, 3.5)
ax_bot.set_ylim(0, density.max() * 1.05)
ax_bot.set_xlabel("$x$")
ax_bot.set_ylabel("empirical density")
time_text = ax_bot.text(
0.02, 0.92, "", transform=ax_bot.transAxes,
fontsize=10, color="#ffd166", family="monospace",
)
# Recompute jitter once -- particles keep their assigned y for visual stability
jitter = np.random.default_rng(SEED + 1).uniform(0, 1, N_PART)
def update(frame):
global fill
pts = paths[frame]
scat.set_offsets(np.column_stack([pts, jitter]))
mean_line.set_xdata([pts.mean(), pts.mean()])
line.set_ydata(density[frame])
fill.remove()
fill = ax_bot.fill_between(x_grid, density[frame], color="#39d2ff", alpha=0.25)
time_text.set_text(
f"t = {times[frame]:5.2f} | "
f"mean = {pts.mean():+.3f} | std = {pts.std():.3f}"
)
return scat, mean_line, line, fill, time_text
FRAME_STRIDE = 4 # render every 4th time step to keep the GIF small
frame_indices = list(range(0, N_STEPS + 1, FRAME_STRIDE))
print(f"Rendering {len(frame_indices)} frames...")
anim = animation.FuncAnimation(
fig, update, frames=frame_indices, interval=40, blit=False,
)
out_path = Path(__file__).with_name("mckean_vlasov.gif")
try:
anim.save(out_path, writer=animation.PillowWriter(fps=25))
print(f"Saved animation: {out_path}")
except Exception as exc:
print(f"Could not save GIF ({exc}); saving last frame as PNG instead.")
update(N_STEPS)
fig.savefig(out_path.with_suffix(".png"), dpi=150)
print(f"Saved PNG: {out_path.with_suffix('.png')}")