Notebooks 07 (topology), 08 (volterra), 10 (bsde) and 14 (mckean_vlasov)
now follow the same pedagogical template as the optimal-control tutorial:
- Theorem / proof markdown PRE-cells stating the equation pivot, with
derivations inspired by the latex coursework on path integrals,
Volterra-Malliavin and math-physics-finance lectures.
- Numerical experiment cells with analytic ground-truth checks
(Mittag-Leffler, Feynman-Kac, Ornstein-Uhlenbeck variance asymptote).
- Markdown POST-cells stating the expected result, how to read each
figure, and the conclusion linking back to the API.
- Concrete real-world applications:
* 07 topology -> physics: persistent H1 detects the hole of a thin
annulus vs a filled disk.
* 08 volterra -> sub-diffusion fractional Fokker-Planck moments.
* 10 bsde -> heat equation expectation as a linear BSDE.
* 14 mckean_vlasov -> opinion dynamics on a population.
All cells executed end-to-end with the rhftlab kernel; outputs (figures,
prints, ground-truth errors) are embedded as proof of work.
Includes the deterministic builder script _build_enriched_v2.py used to
regenerate the four notebooks.
579 KiB
579 KiB
In [1]:
import numpy as np
import matplotlib.pyplot as plt
from optimizr import _core as opt
from scipy.special import gamma as Gamma
plt.rcParams['figure.figsize'] = (10, 4)
plt.rcParams['figure.dpi'] = 110
plt.rcParams['axes.grid'] = True
plt.rcParams['grid.alpha'] = 0.3
rng = np.random.default_rng(0)
errors = {}
print('volterra notebook ready.')
volterra notebook ready.
In [2]:
def mittag_leffler(alpha, z, n_terms=200):
z = np.asarray(z, dtype=float)
out = np.zeros_like(z)
term = np.ones_like(z)
for k in range(n_terms):
out = out + term / Gamma(alpha * k + 1.0)
term = term * z
return out
T, N = 2.0, 800
alphas = [0.3, 0.5, 0.7, 0.9]
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
max_err = 0.0
for a in alphas:
res = opt.solve_fractional_ode(1.0, a, T, N, lambda t, h: -h)
t = np.asarray(res['t_grid']); h_num = np.asarray(res['h'])
h_exact = mittag_leffler(a, -t**a)
err = np.max(np.abs(h_num - h_exact))
max_err = max(max_err, err)
axes[0].plot(t, h_num, label=rf'numerical $\alpha={a}$')
axes[0].plot(t, h_exact, '--', alpha=0.6, label=f'exact $E_{{{a}}}$')
axes[1].semilogy(t[1:], np.abs(h_num - h_exact)[1:], label=rf'$\alpha={a}$')
axes[0].set_xlabel('t'); axes[0].set_ylabel('h(t)')
axes[0].legend(fontsize=7, ncol=2); axes[0].set_title(r'$D^\alpha h = -h$, $h(0)=1$')
axes[1].set_xlabel('t'); axes[1].set_ylabel('|error|')
axes[1].legend(fontsize=8); axes[1].set_title('pointwise error (log scale)')
plt.tight_layout(); plt.show()
errors['fractional_ode_max_err'] = max_err
print(f'max error vs Mittag-Leffler = {max_err:.3e}')
assert max_err < 5e-2
max error vs Mittag-Leffler = 5.625e-04
In [3]:
alpha = 0.5
Ns = [50, 100, 200, 400, 800, 1600]
errs = []
for n in Ns:
res = opt.solve_fractional_ode(1.0, alpha, T, n, lambda t, h: -h)
t = np.asarray(res['t_grid']); h_num = np.asarray(res['h'])
errs.append(np.max(np.abs(h_num - mittag_leffler(alpha, -t**alpha))))
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.loglog(Ns, errs, 'o-', lw=2, label='empirical max-error')
slope_ref = errs[0] * (Ns[0] / np.array(Ns))**alpha
ax.loglog(Ns, slope_ref, '--', label=rf'reference slope $-\alpha = -{alpha}$')
ax.set_xlabel('number of steps $N$'); ax.set_ylabel('max error')
ax.set_title(r'Convergence of fractional Adams ($\alpha = 0.5$)')
ax.legend(); plt.tight_layout(); plt.show()
p = -np.polyfit(np.log(Ns), np.log(errs), 1)[0]
print(f'measured convergence order = {p:.3f} (expected for explicit Adams: {alpha:.3f})')
assert p > 0.3, 'convergence rate too low'
errors['fractional_order'] = abs(p - alpha)
measured convergence order = 0.504 (expected for explicit Adams: 0.500)
In [4]:
H = 0.1
rough_kernel = lambda t: t ** (H - 0.5) / Gamma(H + 0.5)
t_samples = np.geomspace(1e-3, 1.0, 200).tolist()
lift = opt.geometric_grid_lift(rough_kernel, t_samples, 12, 1e-2, 1e4, 20000)
gammas = np.asarray(lift['gammas']); weights = np.asarray(lift['weights'])
t_eval = np.geomspace(1e-3, 1.0, 400)
k_target = np.array([rough_kernel(tt) for tt in t_eval])
k_lift = np.array([np.sum(weights * np.exp(-gammas * tt)) for tt in t_eval])
rel_err = np.max(np.abs(k_lift - k_target) / np.abs(k_target))
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].loglog(t_eval, k_target, label='target $K(t)$')
axes[0].loglog(t_eval, k_lift, '--', label=r'Markovian lift $\sum c_j e^{-\gamma_j t}$')
axes[0].set_xlabel('t'); axes[0].set_ylabel('K(t)')
axes[0].set_title(f'Rough kernel, H = {H}'); axes[0].legend()
axes[1].loglog(t_eval, np.abs(k_lift - k_target) / np.abs(k_target))
axes[1].set_xlabel('t'); axes[1].set_ylabel('relative error')
axes[1].set_title('Lift relative error')
plt.tight_layout(); plt.show()
errors['markovian_lift'] = rel_err
print(f'M = {len(gammas)} OU components, max relative error = {rel_err:.3e}')
assert rel_err < 0.5
M = 12 OU components, max relative error = 1.688e-02
In [5]:
T, N = 2.0, 2000
res = opt.solve_volterra(lambda t: 1.0, lambda dt, y: y, T, N, 100, 1e-13)
t = np.asarray(res['t_grid']); y_num = np.asarray(res['y'])
y_exact = np.exp(t)
err_max = float(np.max(np.abs(y_num - y_exact)))
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].plot(t, y_num, label='numerical')
axes[0].plot(t, y_exact, '--', label=r'exact $e^t$')
axes[0].set_xlabel('t'); axes[0].set_ylabel('y(t)')
axes[0].set_title(r'Volterra : $y = 1 + \int_0^t y$')
axes[0].legend()
axes[1].semilogy(t[1:], np.abs(y_num - y_exact)[1:])
axes[1].set_xlabel('t'); axes[1].set_ylabel('|error|')
axes[1].set_title('pointwise error')
plt.tight_layout(); plt.show()
errors['volterra_exp'] = err_max
print(f'max error vs exp(t) = {err_max:.3e}')
assert err_max < 1e-2
max error vs exp(t) = 1.232e-06
In [6]:
beta0 = 0.5
res = opt.solve_volterra(lambda t: 1.0, lambda dt, y: beta0 * y, T, N, 100, 1e-13)
t = np.asarray(res['t_grid']); B = np.asarray(res['y'])
B_exact = np.exp(beta0 * t)
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(t, B, lw=2, label='numerical birth rate $B(t)$')
ax.plot(t, B_exact, '--', label=r'analytical $e^{\beta_0 t}$')
ax.set_xlabel('t (generations)'); ax.set_ylabel('birth rate')
ax.set_title(r'Renewal equation $B = 1 + \beta_0 \int_0^t B$')
ax.legend(); plt.tight_layout(); plt.show()
err_renewal = float(np.max(np.abs(B - B_exact)))
print(f'max error vs analytical solution = {err_renewal:.3e}')
errors['renewal'] = err_renewal
assert err_renewal < 1e-2
max error vs analytical solution = 5.663e-08
In [7]:
def phi_normal(u):
return (float(np.exp(-0.5*u*u)), 0.0)
x_grid = np.linspace(-5.0, 5.0, 401).tolist()
res = opt.fourier_invert(phi_normal, x_grid, 25.0, 4000)
x = np.asarray(res['x_grid']); f_num = np.asarray(res['density'])
f_exact = (1.0 / np.sqrt(2*np.pi)) * np.exp(-0.5*x*x)
err_max = float(np.max(np.abs(f_num - f_exact)))
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].plot(x, f_num, lw=2, label='Fourier inversion')
axes[0].plot(x, f_exact, '--', label=r'exact $\mathcal{N}(0,1)$')
axes[0].set_xlabel('x'); axes[0].set_ylabel('f(x)')
axes[0].set_title('Density recovery'); axes[0].legend()
axes[1].semilogy(x, np.abs(f_num - f_exact))
axes[1].set_xlabel('x'); axes[1].set_ylabel('|error|')
axes[1].set_title('pointwise error')
plt.tight_layout(); plt.show()
errors['fourier_invert'] = err_max
print(f'max error vs analytical Gaussian = {err_max:.3e}')
assert err_max < 1e-3
max error vs analytical Gaussian = 1.665e-15
In [8]:
fig, ax = plt.subplots(figsize=(8.5, 4.5))
T, N = 4.0, 1200
ax.loglog([1.0], [1.0], alpha=0) # placeholder for log scaling
for alpha in [0.4, 0.6, 0.8, 0.95]:
res = opt.solve_fractional_ode(0.0, alpha, T, N, lambda t, m: 2.0)
t = np.asarray(res['t_grid'])[1:]; m = np.asarray(res['h'])[1:]
m_exact = (2.0 / Gamma(alpha + 1)) * t**alpha
err = float(np.max(np.abs(m - m_exact)))
ax.loglog(t, m, lw=2, label=rf'$\alpha={alpha}$ (err={err:.1e})')
ax.loglog(t, m_exact, '--', lw=1, alpha=0.6)
ax.set_xlabel('t'); ax.set_ylabel(r'MSD $\langle X_t^2 \rangle$')
ax.set_title('Sub-diffusion: fractional Fokker–Planck moment closure')
ax.legend(); plt.tight_layout(); plt.show()
print('Linear regression on log-log curves recovers the slope alpha.')
Linear regression on log-log curves recovers the slope alpha.
In [9]:
print('--- per-test residuals ---')
for k, v in errors.items():
print(f'{k:30s} residual = {v:.3e}')
print('all checks satisfied.')
--- per-test residuals --- fractional_ode_max_err residual = 5.625e-04 fractional_order residual = 4.315e-03 markovian_lift residual = 1.688e-02 volterra_exp residual = 1.232e-06 renewal residual = 5.663e-08 fourier_invert residual = 1.665e-15 all checks satisfied.