Files
optimiz-rs/examples/benchmark_v2.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

240 lines
8.0 KiB
Python
Raw Permalink 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.
"""Honest v2 benchmark for optimiz-rs.
We compare each Rust primitive against the most natural pure-Python /
NumPy reference for the same task. The point is **not** to claim a
universal speedup, but to give users a realistic picture of where the
Rust core wins: intrinsically loopy / sequential algorithms that do
not vectorise cleanly in NumPy.
Run with:
python examples/benchmark_v2.py
"""
from __future__ import annotations
import math
import time
from pathlib import Path
import numpy as np
import optimizr as opt
def _bench(fn, repeat: int = 3) -> float:
best = math.inf
for _ in range(repeat):
t0 = time.perf_counter()
fn()
best = min(best, time.perf_counter() - t0)
return best
# ---------------------------------------------------------------------------
# 1. HMM Baum-Welch -- intrinsically loopy
# ---------------------------------------------------------------------------
def _bench_hmm():
rng = np.random.default_rng(42)
n_obs = 5_000
obs = np.concatenate([
rng.normal(-1.0, 0.5, n_obs // 2),
rng.normal(+1.0, 0.5, n_obs // 2),
]).reshape(-1, 1)
def py_baum_welch():
n = obs.shape[0]
mu = np.array([-0.5, 0.5])
sigma = np.array([1.0, 1.0])
pi = np.array([0.5, 0.5])
A = np.array([[0.9, 0.1], [0.1, 0.9]])
for _ in range(10):
B = np.exp(-(obs - mu) ** 2 / (2 * sigma ** 2)) / (np.sqrt(2 * np.pi) * sigma)
alpha = np.zeros((n, 2))
alpha[0] = pi * B[0]
for t in range(1, n):
alpha[t] = (alpha[t - 1] @ A) * B[t]
alpha[t] /= alpha[t].sum() + 1e-300
beta = np.zeros((n, 2))
beta[-1] = 1.0
for t in range(n - 2, -1, -1):
beta[t] = A @ (B[t + 1] * beta[t + 1])
beta[t] /= beta[t].sum() + 1e-300
gamma = alpha * beta
gamma /= gamma.sum(axis=1, keepdims=True) + 1e-300
mu = (gamma * obs).sum(axis=0) / gamma.sum(axis=0)
sigma = np.sqrt(((obs - mu) ** 2 * gamma).sum(axis=0) / gamma.sum(axis=0))
def rs_hmm():
opt.fit_hmm(obs.flatten().tolist(), 2, 10, 1e-6)
np_t = _bench(py_baum_welch, repeat=2)
rs_t = _bench(rs_hmm, repeat=3)
return ("HMM Baum-Welch (2 states, 5_000 obs, 10 iters)", np_t, rs_t)
# ---------------------------------------------------------------------------
# 2. Differential evolution -- multi-modal global optimisation
# ---------------------------------------------------------------------------
def _bench_differential_evolution():
from scipy.optimize import differential_evolution as scipy_de # type: ignore
rastrigin = lambda x: 10 * len(x) + sum(xi * xi - 10 * math.cos(2 * math.pi * xi) for xi in x)
bounds = [(-5.12, 5.12)] * 5
def py_de():
scipy_de(rastrigin, bounds, maxiter=50, popsize=20, seed=0, tol=0.0, polish=False)
def rs_de():
opt.differential_evolution(rastrigin, bounds, maxiter=50, popsize=20, seed=0)
np_t = _bench(py_de, repeat=2)
rs_t = _bench(rs_de, repeat=3)
return ("Differential evolution (Rastrigin d=5, 50 iters x 20 pop)", np_t, rs_t)
# ---------------------------------------------------------------------------
# 3. Path signature
# ---------------------------------------------------------------------------
def _bench_signature():
rng = np.random.default_rng(0)
path = np.cumsum(rng.standard_normal((300, 3)) * 0.05, axis=0)
def py_signature():
d = path.shape[1]
increments = np.diff(path, axis=0)
s1 = np.zeros(d)
s2 = np.zeros((d, d))
s3 = np.zeros((d, d, d))
for inc in increments:
s1 = s1 + inc
s2 = s2 + 0.5 * np.outer(inc, inc)
for i in range(d):
for j in range(d):
for k in range(d):
s3[i, j, k] += inc[i] * inc[j] * inc[k] / 6.0
def rs_signature():
opt.path_signature(path.tolist(), 3)
np_t = _bench(py_signature, repeat=2)
rs_t = _bench(rs_signature, repeat=3)
return ("Path signature (T=300, d=3, depth=3)", np_t, rs_t)
# ---------------------------------------------------------------------------
# 4. MCMC random-walk Metropolis
# ---------------------------------------------------------------------------
def _bench_mcmc():
n_samples = 5_000
rng = np.random.default_rng(1)
def py_mh():
def logp(x):
a, b = 1.0, 100.0
return -((a - x[0]) ** 2 + b * (x[1] - x[0] ** 2) ** 2) / 20.0
x = np.array([0.0, 0.0])
lp = logp(x)
out = np.zeros((n_samples, 2))
for i in range(n_samples):
cand = x + rng.normal(scale=0.5, size=2)
lpc = logp(cand)
if math.log(rng.random() + 1e-300) < lpc - lp:
x, lp = cand, lpc
out[i] = x
def rs_mh():
# mcmc_sample expects a Python log-density callable; that's a fair
# comparison since the python reference also calls a python logp.
def logp(x):
a, b = 1.0, 100.0
return -((a - x[0]) ** 2 + b * (x[1] - x[0] ** 2) ** 2) / 20.0
try:
opt.mcmc_sample(logp, [0.0, 0.0], n_samples, 0.5)
except TypeError:
# Fallback signature: positional only
opt.mcmc_sample(logp, [0.0, 0.0], n_samples)
np_t = _bench(py_mh, repeat=2)
rs_t = _bench(rs_mh, repeat=3)
return (f"MCMC random-walk MH ({n_samples} samples, d=2)", np_t, rs_t)
# ---------------------------------------------------------------------------
# 5. Hawkes process simulation -- sequential, O(N^2) reference
# ---------------------------------------------------------------------------
def _bench_hawkes():
T = 100.0
mu = 1.0
alpha = 0.6
beta = 1.2
def py_hawkes():
rng = np.random.default_rng(0)
events = []
t = 0.0
lam_max = mu
while t < T:
t += rng.exponential(1.0 / max(lam_max, 1e-9))
if t >= T:
break
lam = mu + alpha * sum(math.exp(-beta * (t - s)) for s in events)
if rng.random() <= lam / lam_max:
events.append(t)
lam_max = lam + alpha
return events
def rs_hawkes():
opt.simulate_hawkes(mu, alpha, beta, T, "exponential", 0)
np_t = _bench(py_hawkes, repeat=2)
rs_t = _bench(rs_hawkes, repeat=3)
return (f"Hawkes process (T={T}, mu={mu}, alpha={alpha}, beta={beta})", np_t, rs_t)
def main():
print("Running v2 benchmarks (best of N runs, single-threaded)\n")
rows = []
for fn in (_bench_hmm, _bench_differential_evolution, _bench_signature,
_bench_mcmc, _bench_hawkes):
try:
rows.append(fn())
except Exception as exc: # pragma: no cover
rows.append((f"{fn.__name__} (FAILED: {exc})", float('nan'), float('nan')))
md = ["| Workload | Pure Python / NumPy | optimiz-rs (Rust) | Speedup |",
"|---|---:|---:|---:|"]
for name, np_t, rs_t in rows:
if math.isnan(np_t) or math.isnan(rs_t):
md.append(f"| {name} | n/a | n/a | n/a |")
continue
speedup = np_t / rs_t if rs_t > 0 else float("inf")
md.append(f"| {name} | {np_t * 1e3:8.2f} ms | {rs_t * 1e3:8.2f} ms | **{speedup:5.1f}×** |")
table = "\n".join(md)
print(table)
out = Path(__file__).with_name("benchmark_v2.md")
out.write_text(
"# optimiz-rs v2.0 benchmark\n\n"
"Best wall-clock over a few runs. Single-threaded. "
"Workloads chosen to be intrinsically loopy / sequential -- the "
"regime where the Rust core delivers a real speedup over a NumPy "
"reference.\n\n"
"Workloads that are fully vectorisable in NumPy (e.g. drift updates "
"for an N-particle SDE with no callback) are not included: a tight "
"NumPy loop on contiguous arrays is hard to beat from Rust through "
"a PyO3 callback boundary.\n\n"
+ table
+ "\n"
)
print(f"\nWritten: {out}")
if __name__ == "__main__":
main()