This file contains performance benchmarks and statistical validation tests for the random number generators in pypomp.
We compare pypomp’s fast approximate inverse CDF samplers against their counterpart samplers in standard libraries (jax.random and scipy.stats).
Environment Setup & Imports
Environment Setup & Imports Log
Code
import osimport jsonUSE_CPU = os.environ.get("USE_CPU", "false").lower() =="true"if USE_CPU: os.environ["JAX_PLATFORMS"] ="cpu"import jaximport jax.numpy as jnpimport numpy as npimport timeimport matplotlib.pyplot as pltimport pypomp.random as pprimport warningsfrom scipy import statsfrom jax.scipy.stats import binom as jax_binomfrom scipy.stats import binom as scipy_binom# Determine benchmarking modesBENCHMARK_GPU = os.environ.get("BENCHMARK_GPU", "false").lower() =="true"BENCHMARK_CPU = os.environ.get("BENCHMARK_CPU", "false").lower() =="true"print("JAX Devices:", jax.devices())print("Using CPU Mode (forced):", USE_CPU)print("BENCHMARK_GPU Mode:", BENCHMARK_GPU)print("BENCHMARK_CPU Mode:", BENCHMARK_CPU)# Detect if JAX is running on GPUHAS_GPU =not USE_CPU andlen(jax.devices()) >0and jax.devices()[0].platform =="gpu"if HAS_GPU:print("GPU Model Used:", jax.devices()[0].device_kind)# Load cached execution times if they existCACHE_FILE ="benchmark_results.json"benchmark_results = {}if os.path.exists(CACHE_FILE):try:withopen(CACHE_FILE, "r") as f: benchmark_results = json.load(f)print("Loaded cached benchmark results from", CACHE_FILE)# Convert older format cache where GPU times were stored under 't_pp' / 't_jax'for category inlist(benchmark_results.keys()):ifisinstance(benchmark_results[category], dict):for name inlist(benchmark_results[category].keys()): entry = benchmark_results[category][name]ifisinstance(entry, dict):if"t_pp"in entry and"t_pp_gpu"notin entry: entry["t_pp_gpu"] = entry.pop("t_pp")if"t_jax"in entry and"t_jax_gpu"notin entry: entry["t_jax_gpu"] = entry.pop("t_jax")exceptExceptionas e:print(f"Error loading cached benchmark results: {e}")
ERROR:2026-07-06 15:40:30,755:jax._src.xla_bridge:491: Jax plugin configuration error: Exception when calling jax_plugins.xla_cuda12.initialize()
Traceback (most recent call last):
File "/home/aaronabk/research/quant/.venv/lib64/python3.12/site-packages/jax/_src/xla_bridge.py", line 489, in discover_pjrt_plugins
plugin_module.initialize()
File "/home/aaronabk/research/quant/.venv/lib64/python3.12/site-packages/jax_plugins/xla_cuda12/__init__.py", line 328, in initialize
_check_cuda_versions(raise_on_first_error=True)
File "/home/aaronabk/research/quant/.venv/lib64/python3.12/site-packages/jax_plugins/xla_cuda12/__init__.py", line 285, in _check_cuda_versions
local_device_count = cuda_versions.cuda_device_count()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
RuntimeError: jaxlib/cuda/versions_helpers.cc:113: operation cuInit(0) failed: Unknown CUDA error 303; cuGetErrorName failed. This probably means that JAX was unable to load the CUDA libraries.
JAX Devices: [CpuDevice(id=0)]
Using CPU Mode (forced): True
BENCHMARK_GPU Mode: False
BENCHMARK_CPU Mode: True
Loaded cached benchmark results from benchmark_results.json
Performance Benchmarks
We measure execution times for generating samples across multiple parameter ranges using both JAX built-ins and pypomp’s fast approximations. We generate samples in batches of 1,000,000 for 20 replications, then record the average time. The number 1,000,000 was chosen because it’s a realistic number of parallel draws that could be made in a particle filtering step across particles and method replications. In our experience, increasing the number of samples further causes the speedup ratio to increase significantly, so this benchmark could be conservative in some circumstances. We also implemented the 20 replications as a JIT-compiled scan to reduce overhead and better approximate how the samplers would be used in practice.
Poisson Performance
Code
n =1_000_000# number of draws per repdef poisson_performance(): key = jax.random.key(42) reps =20 regimes = {"Low Rate (lambda=0.01)": jnp.full((n,), 0.01, dtype=jnp.float32),"Before pypomp Switch (lambda=3.99)": jnp.full((n,), 3.99, dtype=jnp.float32),"After pypomp Switch (lambda=4.01)": jnp.full((n,), 4.01, dtype=jnp.float32),"Before JAX Switch (lambda=9.99)": jnp.full((n,), 9.99, dtype=jnp.float32),"After JAX Switch (lambda=10.01)": jnp.full((n,), 10.01, dtype=jnp.float32),"High Rate (lambda=100.0)": jnp.full((n,), 100.0, dtype=jnp.float32),"Mixed Rates (around switches)": jnp.repeat( jnp.array( [0.01, 3.99, 4.01, 9.99, 10.01, 20.0, 50.0, 100.0], dtype=jnp.float32 ), n //8, ), }if"poisson"notin benchmark_results: benchmark_results["poisson"] = {}# Run/update GPU benchmark if BENCHMARK_GPU is requestedif BENCHMARK_GPU:for name, lam_samples in regimes.items(): key1, key2 = jax.random.split(key) t_pp = run_benchmark(ppr.fast_poisson, key1, lam_samples, reps=reps) *1000 t_jax = run_benchmark(jax.random.poisson, key2, lam_samples, reps=reps) *1000if name notin benchmark_results["poisson"]: benchmark_results["poisson"][name] = {} benchmark_results["poisson"][name]["t_pp_gpu"] = t_pp benchmark_results["poisson"][name]["t_jax_gpu"] = t_jax# Run/update CPU benchmark if BENCHMARK_CPU is requestedif BENCHMARK_CPU:for name, lam_samples in regimes.items(): key1, key2 = jax.random.split(key) t_pp = run_benchmark(ppr.fast_poisson, key1, lam_samples, reps=reps) *1000 t_jax = run_benchmark(jax.random.poisson, key2, lam_samples, reps=reps) *1000if name notin benchmark_results["poisson"]: benchmark_results["poisson"][name] = {} benchmark_results["poisson"][name]["t_pp_cpu"] = t_pp benchmark_results["poisson"][name]["t_jax_cpu"] = t_jax# Save cache if benchmarks were executedif BENCHMARK_GPU or BENCHMARK_CPU:try:withopen(CACHE_FILE, "w") as f: json.dump(benchmark_results, f, indent=2)exceptExceptionas e:print(f"Error saving cache: {e}")# Display GPU table if GPU times are present in cache has_gpu =all("t_pp_gpu"in benchmark_results["poisson"].get(name, {}) for name in regimes)if has_gpu:print("#### GPU Performance Benchmarks")print("| Parameter Regime | ppr.fast_poisson (ms) | jax.random.poisson (ms) | Speedup Ratio |")print("|---|---|---|---|")for name in regimes: entry = benchmark_results["poisson"][name] t_pp = entry["t_pp_gpu"] t_jax = entry["t_jax_gpu"] ratio = t_jax / t_ppprint(f"| {name} | {t_pp:.2f} ms | {t_jax:.2f} ms | **{ratio:.2f}x** |")print("\n")# Display CPU table if CPU times are present in cache has_cpu =all("t_pp_cpu"in benchmark_results["poisson"].get(name, {}) for name in regimes)if has_cpu:print("#### CPU Performance Benchmarks")print("| Parameter Regime | ppr.fast_poisson (ms) | jax.random.poisson (ms) | Speedup Ratio |")print("|---|---|---|---|")for name in regimes: entry = benchmark_results["poisson"][name] t_pp = entry["t_pp_cpu"] t_jax = entry["t_jax_cpu"] ratio = t_jax / t_ppprint(f"| {name} | {t_pp:.2f} ms | {t_jax:.2f} ms | **{ratio:.2f}x** |")print("\n")poisson_performance()
GPU Performance Benchmarks
Parameter Regime
ppr.fast_poisson (ms)
jax.random.poisson (ms)
Speedup Ratio
Low Rate (lambda=0.01)
0.02 ms
0.41 ms
17.31x
Before pypomp Switch (lambda=3.99)
0.02 ms
0.77 ms
31.47x
After pypomp Switch (lambda=4.01)
0.03 ms
0.78 ms
25.94x
Before JAX Switch (lambda=9.99)
0.03 ms
1.08 ms
43.09x
After JAX Switch (lambda=10.01)
0.02 ms
0.47 ms
19.84x
High Rate (lambda=100.0)
0.02 ms
0.36 ms
14.65x
Mixed Rates (around switches)
0.02 ms
1.08 ms
44.33x
CPU Performance Benchmarks
Parameter Regime
ppr.fast_poisson (ms)
jax.random.poisson (ms)
Speedup Ratio
Low Rate (lambda=0.01)
27.57 ms
396.82 ms
14.40x
Before pypomp Switch (lambda=3.99)
27.61 ms
615.85 ms
22.30x
After pypomp Switch (lambda=4.01)
27.59 ms
616.28 ms
22.33x
Before JAX Switch (lambda=9.99)
27.61 ms
789.31 ms
28.59x
After JAX Switch (lambda=10.01)
27.58 ms
519.97 ms
18.85x
High Rate (lambda=100.0)
27.52 ms
367.52 ms
13.35x
Mixed Rates (around switches)
27.52 ms
854.55 ms
31.05x
Binomial Performance
Code
def binomial_performance(): key = jax.random.key(43) reps =20# Mixed parameter grid setup trials = jnp.array([10, 40, 100, 250], dtype=jnp.float32) p = jnp.array([0.01, 0.039, 0.041, 0.099, 0.101, 0.499, 0.501, 0.99], dtype=jnp.float32) trial_grid, p_grid = jnp.meshgrid(trials, p, indexing="ij") trial_flat = trial_grid.reshape(-1) p_flat = p_grid.reshape(-1) n_repeat = n //len(trial_flat) trial_samples = jnp.tile(trial_flat, n_repeat) p_samples = jnp.tile(p_flat, n_repeat) regimes = {"Before pypomp Switch (n=100, p=0.039)": ( jnp.full((n,), 100, dtype=jnp.int32), jnp.full((n,), 0.039, dtype=jnp.float32), ),"After pypomp Switch (n=100, p=0.041)": ( jnp.full((n,), 100, dtype=jnp.int32), jnp.full((n,), 0.041, dtype=jnp.float32), ),"Before JAX Switch (n=100, p=0.099)": ( jnp.full((n,), 100, dtype=jnp.int32), jnp.full((n,), 0.099, dtype=jnp.float32), ),"After JAX Switch (n=100, p=0.101)": ( jnp.full((n,), 100, dtype=jnp.int32), jnp.full((n,), 0.101, dtype=jnp.float32), ),"Before Flip Switch (n=100, p=0.499)": ( jnp.full((n,), 100, dtype=jnp.int32), jnp.full((n,), 0.499, dtype=jnp.float32), ),"After Flip Switch (n=100, p=0.501)": ( jnp.full((n,), 100, dtype=jnp.int32), jnp.full((n,), 0.501, dtype=jnp.float32), ),"Mixed Grid (around switches)": (trial_samples, p_samples), }if"binomial"notin benchmark_results: benchmark_results["binomial"] = {}# Run/update GPU benchmark if BENCHMARK_GPU is requestedif BENCHMARK_GPU:for name, (n_arr, p_arr) in regimes.items(): key1, key2 = jax.random.split(key) t_pp = run_benchmark(ppr.fast_binomial, key1, n_arr, p_arr, reps=reps) *1000 t_jax = run_benchmark(jax.random.binomial, key2, n_arr, p_arr, reps=reps) *1000if name notin benchmark_results["binomial"]: benchmark_results["binomial"][name] = {} benchmark_results["binomial"][name]["t_pp_gpu"] = t_pp benchmark_results["binomial"][name]["t_jax_gpu"] = t_jax# Run/update CPU benchmark if BENCHMARK_CPU is requestedif BENCHMARK_CPU:for name, (n_arr, p_arr) in regimes.items(): key1, key2 = jax.random.split(key) t_pp = run_benchmark(ppr.fast_binomial, key1, n_arr, p_arr, reps=reps) *1000 t_jax = run_benchmark(jax.random.binomial, key2, n_arr, p_arr, reps=reps) *1000if name notin benchmark_results["binomial"]: benchmark_results["binomial"][name] = {} benchmark_results["binomial"][name]["t_pp_cpu"] = t_pp benchmark_results["binomial"][name]["t_jax_cpu"] = t_jax# Save cache if benchmarks were executedif BENCHMARK_GPU or BENCHMARK_CPU:try:withopen(CACHE_FILE, "w") as f: json.dump(benchmark_results, f, indent=2)exceptExceptionas e:print(f"Error saving cache: {e}")# Display GPU table if GPU times are present in cache has_gpu =all("t_pp_gpu"in benchmark_results["binomial"].get(name, {}) for name in regimes)if has_gpu:print("#### GPU Performance Benchmarks")print("| Parameter Regime | ppr.fast_binomial (ms) | jax.random.binomial (ms) | Speedup Ratio |")print("|---|---|---|---|")for name in regimes: entry = benchmark_results["binomial"][name] t_pp = entry["t_pp_gpu"] t_jax = entry["t_jax_gpu"] ratio = t_jax / t_ppprint(f"| {name} | {t_pp:.2f} ms | {t_jax:.2f} ms | **{ratio:.2f}x** |")print("\n")# Display CPU table if CPU times are present in cache has_cpu =all("t_pp_cpu"in benchmark_results["binomial"].get(name, {}) for name in regimes)if has_cpu:print("#### CPU Performance Benchmarks")print("| Parameter Regime | ppr.fast_binomial (ms) | jax.random.binomial (ms) | Speedup Ratio |")print("|---|---|---|---|")for name in regimes: entry = benchmark_results["binomial"][name] t_pp = entry["t_pp_cpu"] t_jax = entry["t_jax_cpu"] ratio = t_jax / t_ppprint(f"| {name} | {t_pp:.2f} ms | {t_jax:.2f} ms | **{ratio:.2f}x** |")print("\n")binomial_performance()
GPU Performance Benchmarks
Parameter Regime
ppr.fast_binomial (ms)
jax.random.binomial (ms)
Speedup Ratio
Before pypomp Switch (n=100, p=0.039)
0.02 ms
0.77 ms
31.77x
After pypomp Switch (n=100, p=0.041)
0.02 ms
0.78 ms
32.11x
Before JAX Switch (n=100, p=0.099)
0.02 ms
1.04 ms
43.64x
After JAX Switch (n=100, p=0.101)
0.02 ms
0.51 ms
21.73x
Before Flip Switch (n=100, p=0.499)
0.03 ms
0.47 ms
18.58x
After Flip Switch (n=100, p=0.501)
0.02 ms
0.47 ms
19.22x
Mixed Grid (around switches)
0.02 ms
1.05 ms
43.88x
CPU Performance Benchmarks
Parameter Regime
ppr.fast_binomial (ms)
jax.random.binomial (ms)
Speedup Ratio
Before pypomp Switch (n=100, p=0.039)
15.36 ms
640.05 ms
41.68x
After pypomp Switch (n=100, p=0.041)
15.15 ms
649.51 ms
42.88x
Before JAX Switch (n=100, p=0.099)
15.15 ms
806.00 ms
53.19x
After JAX Switch (n=100, p=0.101)
15.29 ms
581.09 ms
38.00x
Before Flip Switch (n=100, p=0.499)
15.23 ms
522.80 ms
34.33x
After Flip Switch (n=100, p=0.501)
15.16 ms
518.39 ms
34.18x
Mixed Grid (around switches)
15.25 ms
877.63 ms
57.54x
Gamma Performance
Code
def gamma_performance(): key = jax.random.key(44) reps =20 alpha = jnp.array([0.1, 0.5, 0.99, 1.01, 2.0, 10.0, 50.0, 100.0], dtype=jnp.float32) alpha_samples = jnp.repeat(alpha, n //len(alpha)) regimes = {"Before JAX Switch (alpha=0.99)": jnp.full((n,), 0.99, dtype=jnp.float32),"After JAX Switch (alpha=1.01)": jnp.full((n,), 1.01, dtype=jnp.float32),"Mixed Shapes (around switch)": alpha_samples, }if"gamma"notin benchmark_results: benchmark_results["gamma"] = {}# Run/update GPU benchmark if BENCHMARK_GPU is requestedif BENCHMARK_GPU:for name, alpha_arr in regimes.items(): key1, key2 = jax.random.split(key) t_pp = run_benchmark(ppr.fast_gamma, key1, alpha_arr, reps=reps) *1000 t_jax = run_benchmark(jax.random.gamma, key2, alpha_arr, reps=reps) *1000if name notin benchmark_results["gamma"]: benchmark_results["gamma"][name] = {} benchmark_results["gamma"][name]["t_pp_gpu"] = t_pp benchmark_results["gamma"][name]["t_jax_gpu"] = t_jax# Run/update CPU benchmark if BENCHMARK_CPU is requestedif BENCHMARK_CPU:for name, alpha_arr in regimes.items(): key1, key2 = jax.random.split(key) t_pp = run_benchmark(ppr.fast_gamma, key1, alpha_arr, reps=reps) *1000 t_jax = run_benchmark(jax.random.gamma, key2, alpha_arr, reps=reps) *1000if name notin benchmark_results["gamma"]: benchmark_results["gamma"][name] = {} benchmark_results["gamma"][name]["t_pp_cpu"] = t_pp benchmark_results["gamma"][name]["t_jax_cpu"] = t_jax# Save cache if benchmarks were executedif BENCHMARK_GPU or BENCHMARK_CPU:try:withopen(CACHE_FILE, "w") as f: json.dump(benchmark_results, f, indent=2)exceptExceptionas e:print(f"Error saving cache: {e}")# Display GPU table if GPU times are present in cache has_gpu =all("t_pp_gpu"in benchmark_results["gamma"].get(name, {}) for name in regimes)if has_gpu:print("#### GPU Performance Benchmarks")print("| Parameter Regime | ppr.fast_gamma (ms) | jax.random.gamma (ms) | Speedup Ratio |")print("|---|---|---|---|")for name in regimes: entry = benchmark_results["gamma"][name] t_pp = entry["t_pp_gpu"] t_jax = entry["t_jax_gpu"] ratio = t_jax / t_ppprint(f"| {name} | {t_pp:.2f} ms | {t_jax:.2f} ms | **{ratio:.2f}x** |")print("\n")# Display CPU table if CPU times are present in cache has_cpu =all("t_pp_cpu"in benchmark_results["gamma"].get(name, {}) for name in regimes)if has_cpu:print("#### CPU Performance Benchmarks")print("| Parameter Regime | ppr.fast_gamma (ms) | jax.random.gamma (ms) | Speedup Ratio |")print("|---|---|---|---|")for name in regimes: entry = benchmark_results["gamma"][name] t_pp = entry["t_pp_cpu"] t_jax = entry["t_jax_cpu"] ratio = t_jax / t_ppprint(f"| {name} | {t_pp:.2f} ms | {t_jax:.2f} ms | **{ratio:.2f}x** |")print("\n")gamma_performance()
GPU Performance Benchmarks
Parameter Regime
ppr.fast_gamma (ms)
jax.random.gamma (ms)
Speedup Ratio
Before JAX Switch (alpha=0.99)
0.03 ms
0.61 ms
18.25x
After JAX Switch (alpha=1.01)
0.03 ms
0.95 ms
30.42x
Mixed Shapes (around switch)
0.03 ms
0.84 ms
25.46x
CPU Performance Benchmarks
Parameter Regime
ppr.fast_gamma (ms)
jax.random.gamma (ms)
Speedup Ratio
Before JAX Switch (alpha=0.99)
90.59 ms
1782.78 ms
19.68x
After JAX Switch (alpha=1.01)
92.64 ms
2884.97 ms
31.14x
Mixed Shapes (around switch)
90.58 ms
2493.77 ms
27.53x
Negative Binomial Performance
Code
def rnbinom_performance(): key = jax.random.key(45) reps =20 size = jnp.array([1.0, 10.0, 100.0], dtype=jnp.float32) p = jnp.array([0.1, 0.4998, 0.5003, 0.7138, 0.7148, 0.9], dtype=jnp.float32) size_grid, p_grid = jnp.meshgrid(size, p, indexing="ij") size_flat = size_grid.reshape(-1) p_flat = p_grid.reshape(-1) n_repeat =max(1, n //len(size_flat)) size_samples = jnp.tile(size_flat, n_repeat) p_samples = jnp.tile(p_flat, n_repeat) regimes = {"Before Poisson Switch (size=10.0, p=0.7148)": ( jnp.full((n,), 10.0, dtype=jnp.float32), jnp.full((n,), 10.0/13.99, dtype=jnp.float32), ),"After Poisson Switch (size=10.0, p=0.7138)": ( jnp.full((n,), 10.0, dtype=jnp.float32), jnp.full((n,), 10.0/14.01, dtype=jnp.float32), ),"Before JAX Poisson Switch (size=10.0, p=0.5003)": ( jnp.full((n,), 10.0, dtype=jnp.float32), jnp.full((n,), 10.0/19.99, dtype=jnp.float32), ),"After JAX Poisson Switch (size=10.0, p=0.4998)": ( jnp.full((n,), 10.0, dtype=jnp.float32), jnp.full((n,), 10.0/20.01, dtype=jnp.float32), ),"Mixed Grid (around switches)": (size_samples, p_samples), }if"nbinomial"notin benchmark_results: benchmark_results["nbinomial"] = {}# Run/update GPU benchmark if BENCHMARK_GPU is requestedif BENCHMARK_GPU:for name, (size_arr, p_arr) in regimes.items(): key1, _ = jax.random.split(key) t_pp = run_benchmark(ppr.fast_nbinomial, key1, size_arr, p=p_arr, reps=reps) *1000if name notin benchmark_results["nbinomial"]: benchmark_results["nbinomial"][name] = {} benchmark_results["nbinomial"][name]["t_pp_gpu"] = t_pp# Run/update CPU benchmark if BENCHMARK_CPU is requestedif BENCHMARK_CPU:for name, (size_arr, p_arr) in regimes.items(): key1, _ = jax.random.split(key) t_pp = run_benchmark(ppr.fast_nbinomial, key1, size_arr, p=p_arr, reps=reps) *1000if name notin benchmark_results["nbinomial"]: benchmark_results["nbinomial"][name] = {} benchmark_results["nbinomial"][name]["t_pp_cpu"] = t_pp# Save cache if benchmarks were executedif BENCHMARK_GPU or BENCHMARK_CPU:try:withopen(CACHE_FILE, "w") as f: json.dump(benchmark_results, f, indent=2)exceptExceptionas e:print(f"Error saving cache: {e}")# Display GPU table if GPU times are present in cache has_gpu =all("t_pp_gpu"in benchmark_results["nbinomial"].get(name, {}) for name in regimes)if has_gpu:print("#### GPU Performance Benchmarks")print("| Parameter Regime | ppr.fast_nbinomial (ms) |")print("|---|---|")for name in regimes: t_pp = benchmark_results["nbinomial"][name]["t_pp_gpu"]print(f"| {name} | {t_pp:.2f} ms |")print("\n")# Display CPU table if CPU times are present in cache has_cpu =all("t_pp_cpu"in benchmark_results["nbinomial"].get(name, {}) for name in regimes)if has_cpu:print("#### CPU Performance Benchmarks")print("| Parameter Regime | ppr.fast_nbinomial (ms) |")print("|---|---|")for name in regimes: t_pp = benchmark_results["nbinomial"][name]["t_pp_cpu"]print(f"| {name} | {t_pp:.2f} ms |")print("\n")rnbinom_performance()
GPU Performance Benchmarks
Parameter Regime
ppr.fast_nbinomial (ms)
Before Poisson Switch (size=10.0, p=0.7148)
0.07 ms
After Poisson Switch (size=10.0, p=0.7138)
0.07 ms
Before JAX Poisson Switch (size=10.0, p=0.5003)
0.07 ms
After JAX Poisson Switch (size=10.0, p=0.4998)
0.07 ms
Mixed Grid (around switches)
0.07 ms
CPU Performance Benchmarks
Parameter Regime
ppr.fast_nbinomial (ms)
Before Poisson Switch (size=10.0, p=0.7148)
147.36 ms
After Poisson Switch (size=10.0, p=0.7138)
147.28 ms
Before JAX Poisson Switch (size=10.0, p=0.5003)
147.29 ms
After JAX Poisson Switch (size=10.0, p=0.4998)
147.21 ms
Mixed Grid (around switches)
147.71 ms
Statistical Distance Benchmarks
To quantify the statistical accuracy of the fast approximate samplers relative to the reference JAX/SciPy samplers, we compute the Wasserstein Distance (1st Wasserstein metric) across a range of parameter values using 1,000,000 generated samples for each.
We plot both:
The Absolute Wasserstein Distance (transport cost in the units of the random variable).
The Relative Wasserstein Distance (normalized by the standard deviation of the reference distribution, \(W_1 / \sigma\)), providing a scale-invariant measure of the approximation error relative to the natural spread of the distribution.
Absolute and relative (scale-normalized) Wasserstein distance across parameter ranges.
Quantile Function Miss Rates
To give a sense of how often pypomp’s approximate inverse CDF implementation errs, we compare the approximate quantile functions poissoninv and binominv against the exact quantile functions from SciPy (scipy.stats.poisson.ppf and scipy.stats.binom.ppf). We evaluate the miss rate, defined as the proportion of \(1,000,000\) random queries \(u \in [0.0001, 0.9999]\) for which the approximate quantile differs from the exact integer quantile.
Code
import scipy.stats as scipy_statspoisson_results = []lam_vals = [0.1, 1.0, 5.0, 10.0, 50.0, 100.0, 500.0, 1000.0]num_samples =1_000_000key = jax.random.key(42)u = jax.random.uniform(key, (num_samples,), minval=0.0001, maxval=0.9999)# --- Poisson Miss Rates ---for lam in lam_vals: approx_q = ppr.poissoninv(u, lam) exact_q = scipy_stats.poisson.ppf(np.array(u), lam) misses = np.sum(np.array(approx_q) != exact_q) miss_rate = misses / num_samples poisson_results.append((lam, miss_rate))# --- Binomial Miss Rates ---n_vals = [10, 100, 1000]p_vals = [0.01, 0.1, 0.5, 0.9, 0.99]binomial_results = {n: [] for n in n_vals}for n in n_vals:for p in p_vals: approx_q = ppr.binominv(u, n, p, exact_max=5) exact_q = scipy_stats.binom.ppf(np.array(u), n, p) misses = np.sum(np.array(approx_q) != exact_q) miss_rate = misses / num_samples binomial_results[n].append((p, miss_rate))
Quantile Miss Rate Visualizations
Code
# Set up matplotlib styleplt.rcParams.update({'font.size': 10,'axes.labelsize': 11,'axes.titlesize': 12,'xtick.labelsize': 9,'ytick.labelsize': 9,'grid.color': '#DDDDDD','grid.linestyle': ':','grid.linewidth': 0.8})fig, axes = plt.subplots(1, 2, figsize=(12, 5))# 1. Poisson Plotax1 = axes[0]lams = [r[0] for r in poisson_results]p_miss = [r[1] for r in poisson_results]# For log-scale plotting, clamp 0 to a small value (e.g. 1e-7) so they show up on the bottom linep_miss_plot = [m if m >0else1e-7for m in p_miss]ax1.plot(lams, p_miss_plot, color="tab:blue", marker="o", linewidth=2)ax1.set_xscale("log")ax1.set_yscale("log")ax1.set_ylim(1e-8, 1e-3)ax1.set_xlabel("Rate (lambda)")ax1.set_ylabel("Quantile Miss Rate (clamped at 1e-7 for 0.0)")ax1.set_title("Poisson Quantile Miss Rate vs. Lambda")ax1.grid(True, which="both", linestyle=":", alpha=0.5)# 2. Binomial Plotax2 = axes[1]colors = ["tab:blue", "tab:orange", "tab:green"]markers = ["o", "s", "^"]for i, n inenumerate(n_vals): p_vals_n = [r[0] for r in binomial_results[n]] b_miss = [r[1] for r in binomial_results[n]] b_miss_plot = [m if m >0else1e-7for m in b_miss] ax2.plot(p_vals_n, b_miss_plot, color=colors[i], marker=markers[i], linewidth=2, label=f"n = {n}")ax2.set_yscale("log")ax2.set_ylim(1e-8, 1e-2)ax2.set_xlabel("Success Probability (p)")ax2.set_ylabel("Quantile Miss Rate (clamped at 1e-7 for 0.0)")ax2.set_title("Binomial Quantile Miss Rate vs. p")ax2.legend()ax2.grid(True, which="both", linestyle=":", alpha=0.5)plt.tight_layout()plt.show()
Quantile miss rates for the Poisson and Binomial fast approximate inverse CDF implementations.
Distribution Comparisons & Visualizations
We validate the statistical accuracy of the fast approximate samplers using histograms/density plots and Quantile-Quantile (Q-Q) plots. We want the histograms/density plots to overlap as much as possible and the Q-Q plots to fall along the line \(y = x\) as much as possible.
For discrete distributions (Poisson, Binomial, and Negative Binomial), the Q-Q plots use a small amount of random jitter. Because discrete variables only take integer values, their quantiles lie on a grid, packing all of the points onto a few spots and making it difficult to tell if a point off of the \(y = x\) line is a handful of points or thousands of them. Adding jitter spreads the points slightly so that the relative density/number of points at each coordinate pair is clearly visible.
Binomial Distribution comparison: Histogram and Q-Q Plot.
Gamma Distribution Comparison
For continuous Gamma distributions, we compare the shape of the density using smooth Kernel Density Estimation (KDE) plots.
Log-KDE and Log-Scale Q-Q Plots for Extreme Skewness (\(\alpha=0.01\))
For extremely low shape parameters, values span dozens of orders of magnitude. A standard linear KDE is heavily distorted, and setting the x-axis to a log-scale mathematically distorts the area under the curve.
To resolve this, we perform KDE on the log-transformed variable \(Y = \log_{10}(X)\) and plot it on a linear scale, relabeling the ticks to \(10^y\). Because \(P(10^a \le X \le 10^b) = \int_a^b f_Y(y) dy\), the visual area under the curve maps exactly to the true probability mass. Monotonicity of \(\log\) preserves the exact quantile mapping in the Q-Q plots.