24556f51d7
- 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.
154 lines
5.2 KiB
Python
154 lines
5.2 KiB
Python
"""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(
|
||
"McKean–Vlasov 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')}")
|