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.
16 KiB
16 KiB
In [ ]:
import numpy as np
import matplotlib.pyplot as plt
from optimizr import _core as opt
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(42)
errors = {}
print('BSDE notebook ready.')
In [ ]:
rho, T, n = 0.3, 1.0, 200
res = opt.linear_bsde_constant_coeffs(-rho, 0.0, 0.0, 1.0, n, T, 0.5)
tg = np.array(res['time_grid'])
yg = np.array(res['y'])
analytic = np.exp(-rho * (T - tg))
err = float(np.max(np.abs(yg - analytic)))
errors['theta_scheme_max_err'] = err
print(f'Y0 numerical = {yg[0]:.6f}')
print(f'Y0 analytic = {np.exp(-rho * T):.6f}')
print(f'max grid error = {err:.2e}')
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].plot(tg, yg, lw=2, label=r'$\theta$-scheme')
axes[0].plot(tg, analytic, '--', lw=1.5, label=r'$\xi e^{a(T-t)}$')
axes[0].set_xlabel('t'); axes[0].set_ylabel(r'$Y_t$')
axes[0].set_title('Linear BSDE — Crank–Nicolson vs analytic')
axes[0].legend()
axes[1].semilogy(tg, np.abs(yg - analytic) + 1e-16)
axes[1].set_xlabel('t'); axes[1].set_ylabel('|error|')
axes[1].set_title('pointwise error (log scale)')
plt.tight_layout(); plt.show()
assert err < 1e-3
In [ ]:
ns = [25, 50, 100, 200, 400, 800]
errs = []
for n in ns:
r = opt.linear_bsde_constant_coeffs(-rho, 0.0, 0.0, 1.0, n, T, 0.5)
errs.append(abs(r['y'][0] - np.exp(-rho * T)))
fig, ax = plt.subplots(figsize=(8, 4.5))
ax.loglog(ns, errs, 'o-', lw=2, label='empirical max error')
ax.loglog(ns, [errs[0] * (ns[0] / n)**2 for n in ns], ':', label=r'reference slope $-2$')
ax.set_xlabel('number of steps $N$'); ax.set_ylabel(r'$|Y_0 - e^{-\rho T}|$')
ax.set_title('Crank–Nicolson convergence')
ax.legend(); plt.tight_layout(); plt.show()
slope = -np.polyfit(np.log(ns), np.log(errs), 1)[0]
print(f'measured slope = {slope:.3f} (theory : 2.0)')
errors['convergence_slope'] = abs(slope - 2.0)
assert slope > 1.7
In [ ]:
# Feynman--Kac sanity check: discount of a deterministic constant terminal.
# Y_t = xi * exp(-rho (T - t)) and Y_0 = xi exp(-rho T).
xi_values = [0.5, 1.0, 2.0, 3.0]
fig, ax = plt.subplots(figsize=(8, 4.5))
for xi in xi_values:
res = opt.linear_bsde_constant_coeffs(-rho, 0.0, 0.0, xi, n, T, 0.5)
tg = np.array(res['time_grid'])
ax.plot(tg, res['y'], lw=2, label=f'xi = {xi}')
ax.plot(tg, xi * np.exp(-rho * (T - tg)), '--', alpha=0.6)
ax.set_xlabel('t'); ax.set_ylabel(r'$Y_t = \xi e^{-\rho(T-t)}$')
ax.set_title('Linearity check — multiple terminal payoffs')
ax.legend(); plt.tight_layout(); plt.show()
print('All four trajectories overlay their analytical exponentials.')
In [ ]:
M = 10_000
W_T = rng.standard_normal(M) * np.sqrt(T)
mc_value = float(np.mean(np.exp(-rho * T) * W_T**2))
res = opt.linear_bsde_constant_coeffs(-rho, 0.0, 0.0, T, n, T, 0.5)
y0_pde = float(res['y'][0])
print(f'Monte Carlo (M={M}) : Y0 = {mc_value:.6f}')
print(f'BSDE primitive : Y0 = {y0_pde:.6f}')
print(f'analytic : Y0 = {T * np.exp(-rho * T):.6f}')
rel = abs(y0_pde - T * np.exp(-rho * T)) / (T * np.exp(-rho * T))
print(f'BSDE relative error : {rel:.2%}')
errors['mc_consistency'] = rel
assert rel < 1e-2
ts = np.linspace(0, T, 50)
paths = np.cumsum(rng.standard_normal((40, len(ts))) * np.sqrt(T / len(ts)), axis=1)
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
for p in paths:
axes[0].plot(ts, p, alpha=0.5)
axes[0].set_xlabel('t'); axes[0].set_ylabel(r'$W_t$')
axes[0].set_title('40 Brownian sample paths')
tg = np.array(res['time_grid']); yg = np.array(res['y'])
axes[1].plot(tg, yg, lw=2, color='C3', label='BSDE primitive')
axes[1].axhline(mc_value, ls='--', color='C0', label=f'MC Y0 = {mc_value:.3f}')
axes[1].axhline(T * np.exp(-rho * T), ls=':', color='black',
label=f'analytic = {T*np.exp(-rho*T):.3f}')
axes[1].set_xlabel('t'); axes[1].set_ylabel(r'$Y_t$')
axes[1].set_title('Discounted expectation')
axes[1].legend(); plt.tight_layout(); plt.show()
In [ ]:
ts = np.linspace(0, T, 80)
u_undiscounted = (T - ts)
u_discounted = np.exp(-rho * (T - ts)) * u_undiscounted
fig, ax = plt.subplots(figsize=(8, 4.5))
ax.plot(ts, u_undiscounted, lw=2, label=r'$u(t, 0) = T - t$ (heat equation)')
ax.plot(ts, u_discounted, lw=2, label=r'$\tilde u(t, 0) = e^{-\rho(T-t)}(T - t)$')
ax.scatter([0], [T * np.exp(-rho * T)], color='red', zorder=5,
label=rf'$Y_0 = T e^{{-\rho T}} = {T * np.exp(-rho*T):.3f}$')
ax.set_xlabel('t'); ax.set_ylabel('value at $x = 0$')
ax.set_title(r'Heat equation expectation $\xi(x) = x^2$ — Feynman--Kac')
ax.legend(); plt.tight_layout(); plt.show()
print('BSDE primitive matches the deterministic Feynman--Kac value.')
In [ ]:
print('--- per-test residuals ---')
for k, v in errors.items():
print(f'{k:30s} residual = {v:.3e}')
print('all checks satisfied.')