Files
optimiz-rs/scripts/enrich_v2_notebooks.py
T
ThotDjehuty dd51156174 docs(v2.0.0-alpha.4): enrich v2.0 notebooks with FR sandwich + real-world examples
Each of the eight v2.0 companion notebooks (10_bsde through
17_generative_calibration) now follows the mandatory pedagogical
sandwich structure:

  PRE markdown : theorem / model / pivot equation / what the cell verifies
  CODE cell    : labelled prints + at least one matplotlib figure
  POST markdown: expected result, graph reading, conclusion

Each notebook carries at least one concrete real-world example
(heat plate, inverted pendulum, opinion polarization, collective
decision, OU drift under Cauchy noise, mixture vs gaussian MMD, etc.)

Generator script: scripts/enrich_v2_notebooks.py
Doc plots refreshed via scripts/inject_doc_plots.py.
2026-05-12 16:07:42 +02:00

1274 lines
46 KiB
Python
Raw 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.
"""Regenerate the eight v2.0 companion notebooks with the mandatory
pedagogical sandwich structure (PRE-markdown -> code -> POST-markdown)
and an additional real-world / concrete example per notebook.
Each notebook is then executed end-to-end with the rhftlab kernel so the
committed .ipynb carries figures and prints as proof of work.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
NB_DIR = Path(__file__).resolve().parents[1] / "examples" / "notebooks"
def md(src: str) -> dict:
return {
"cell_type": "markdown",
"metadata": {},
"source": src.splitlines(keepends=True),
}
def code(src: str) -> dict:
return {
"cell_type": "code",
"execution_count": None,
"metadata": {},
"outputs": [],
"source": src.splitlines(keepends=True),
}
PREAMBLE = """import numpy as np
import matplotlib.pyplot as plt
from optimizr import _core as opt
plt.rcParams['figure.figsize'] = (8.5, 4.5)
plt.rcParams['figure.dpi'] = 110
plt.rcParams['axes.grid'] = True
plt.rcParams['grid.alpha'] = 0.3
"""
def write_notebook(name: str, title_md: str, cells: list[dict]) -> Path:
nb = {
"cells": [md(title_md), code(PREAMBLE)] + cells,
"metadata": {
"kernelspec": {
"display_name": "rhftlab",
"language": "python",
"name": "rhftlab",
},
"language_info": {"name": "python", "version": "3.11"},
},
"nbformat": 4,
"nbformat_minor": 5,
}
path = NB_DIR / name
path.write_text(json.dumps(nb, indent=1))
return path
# ---------------------------------------------------------------------------
# 10 — BSDE
# ---------------------------------------------------------------------------
NB10_TITLE = """# 10 — BSDE θ-scheme (linear, constant coefficients)
CPU-only CrankNicolson scheme for linear backward stochastic
differential equations. Doc page:
[bsde.rst](../../docs/source/algorithms/bsde.rst).
Each computational cell is sandwiched between a *PRE* markdown
(theoretical reminder) and a *POST* markdown (expected result and
graph reading).
"""
NB10_CELLS = [
md("""## Cellule 1 — Vérification du schéma θ contre la solution analytique
**Théorème (Pardoux-Peng 1990) — BSDE linéaire à coefficients constants.**
Pour $a$, $b$, $c$ déterministes constants et terminal $\\xi$, la
solution déterministe (lorsque $b = c = 0$) de
$$-dY_t = (a Y_t)\\,dt - Z_t\\,dW_t,\\qquad Y_T = \\xi$$
est $Y_t = \\xi e^{a(T-t)}$.
**Équation pivot.**
$$Y_t = \\xi\\,e^{a(T-t)}.$$
**Démonstration (esquisse).** En l'absence de bruit ($b = 0$) et de
forçage ($c = 0$), l'EDS rétrograde devient l'EDO $\\dot Y = -aY$
intégrée en $Y_t = \\xi e^{a(T-t)}$. $\\square$
**Ce que la cellule vérifie.** Le schéma $\\theta = 0.5$ (CrankNicolson)
reproduit la décroissance exponentielle pour $a = -0.3$, $T = 1$.
"""),
code("""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)))
print(f\"Y0 numérique = {yg[0]:.6f}\")
print(f\"Y0 analytique = {analytic[0]:.6f}\")
print(f\"Erreur max sur la grille : {err:.2e}\")
fig, ax = plt.subplots()
ax.plot(tg, yg, lw=2, label='θ-scheme')
ax.plot(tg, analytic, '--', lw=1.5, label=r'$\\xi e^{-\\rho(T-t)}$')
ax.set_xlabel('t'); ax.set_ylabel(r'$Y_t$')
ax.set_title(\"BSDE linéaire — CrankNicolson vs analytique\")
ax.legend()
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** $Y_0 \\approx 0.7408$, erreur max $< 10^{-3}$.
**Lecture du graphique.** Les deux courbes se superposent visuellement.
**Conclusion.** Le primitive `linear_bsde_constant_coeffs` est calibré
sur le test analytique exponentiel ; il est utilisé comme brique pour
les BSDE non-linéaires (cellule 3).
"""),
md("""## Cellule 2 — Étude de convergence en $\\Delta t$
**Théorème (CrankNicolson, ordre 2).** Pour une EDO linéaire
$\\dot y = -a y$, le schéma $\\theta = 1/2$ vérifie
$|y_n - y(t_n)| = O(\\Delta t^2)$.
**Équation pivot.**
$$\\log\\,\\text{erreur}(n) \\;\\approx\\; -2\\log n + C.$$
**Ce que la cellule vérifie.** On trace $|Y_0^{(n)} - e^{-\\rho T}|$
en log-log et on compare à la pente $-2$.
"""),
code("""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)))
for n, e in zip(ns, errs):
print(f\"n = {n:4d} -> erreur = {e:.3e}\")
fig, ax = plt.subplots()
ax.loglog(ns, errs, 'o-', lw=2, label='erreur empirique')
ax.loglog(ns, [errs[0] * (ns[0] / n) ** 2 for n in ns], ':',
label=r'pente $-2$ (référence)')
ax.set_xlabel('n_steps'); ax.set_ylabel(r'$|Y_0 - e^{-\\rho T}|$')
ax.set_title(\"Convergence de CrankNicolson\")
ax.legend()
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** Les points s'alignent sur la pente $-2$.
**Lecture du graphique.** Parallélisme avec la droite pointillée.
**Conclusion.** Précision $10^{-6}$ atteinte avec $n \\approx 800$.
"""),
md("""## Cellule 3 — Exemple concret : actualisation d'une espérance terminale
**Modèle utilisé.** Pour un brownien $W_t$, la valeur actualisée
$$Y_t = \\mathbb{E}\\!\\left[e^{-\\rho(T-t)}\\,W_T^2\\,\\big|\\,\\mathcal{F}_t\\right]$$
satisfait l'EDP de FeynmanKac
$\\partial_t u + \\tfrac{1}{2}\\partial_{xx} u - \\rho u = 0$,
$u(T, x) = x^2$.
**Équation pivot (cas déterministe).** $\\mathbb{E}[W_T^2] = T$, donc
$$Y_0 = e^{-\\rho T}\\,T.$$
**Ce que la cellule vérifie.** On compare le primitive `linear_bsde`
(avec terminal $\\xi = T$ — espérance déterministe de $W_T^2$) à une
simulation Monte Carlo de $10^4$ trajectoires.
"""),
code("""rng = np.random.default_rng(0)
M = 10_000
W_T = rng.standard_normal(M) * np.sqrt(T)
mc_value = np.exp(-rho * T) * float(np.mean(W_T ** 2))
# valeur déterministe via le primitive (xi = E[W_T^2] = T)
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\"Écart relatif : {abs(y0_pde - mc_value)/mc_value:.2%}\")
ts = np.linspace(0, T, 50)
paths = np.cumsum(rng.standard_normal((20, 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_title(\"20 trajectoires browniennes\")
axes[0].set_xlabel('t'); axes[0].set_ylabel(r'$W_t$')
tg = np.array(res['time_grid'])
yg = np.array(res['y'])
axes[1].plot(tg, yg, lw=2, color='C3', label='BSDE')
axes[1].axhline(mc_value, ls='--', color='C0',
label=f'Monte Carlo Y0 = {mc_value:.3f}')
axes[1].set_title(\"Valeur actualisée déterministe\")
axes[1].set_xlabel('t'); axes[1].set_ylabel(r'$Y_t$')
axes[1].legend()
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** $Y_0 = T \\cdot e^{-\\rho T} \\approx 0.7408$,
estimateur Monte Carlo à moins de 3 %.
**Lecture du graphique.** Faisceau brownien à gauche ; valeur actualisée
et niveau Monte Carlo à droite — ils coïncident.
**Conclusion.** La primitive BSDE évite la simulation Monte Carlo
quand le terminal est une fonctionnelle simple du brownien.
"""),
]
# ---------------------------------------------------------------------------
# 11 — PDE
# ---------------------------------------------------------------------------
NB11_TITLE = """# 11 — PDE solvers (FokkerPlanck, Poisson)
CPU-only finite-difference solvers. Doc page:
[pde.rst](../../docs/source/algorithms/pde.rst).
"""
NB11_CELLS = [
md("""## Cellule 1 — Diffusion d'une gaussienne (FokkerPlanck 1-D)
**Théorème (FokkerPlanck / Kolmogorov forward).** Pour la diffusion
$dX_t = \\mu\\,dt + \\sigma\\,dW_t$, la densité $p(t, x)$ vérifie
$$\\partial_t p = -\\mu\\,\\partial_x p + \\tfrac{1}{2}\\sigma^2\\,\\partial_{xx} p.$$
**Équation pivot (densité gaussienne).**
$$p(t, x) = \\frac{1}{\\sqrt{2\\pi(\\sigma^2 t + s_0^2)}}
\\exp\\!\\left(-\\frac{(x-\\mu t)^2}{2(\\sigma^2 t + s_0^2)}\\right).$$
**Ce que la cellule vérifie.** Le primitive
`fokker_planck_constant(mu, sigma_sq, init_sigma, ...)` reproduit la
gaussienne analytique pour $\\mu = 0.1$, $\\sigma^2 = 0.16$,
$s_0 = 0.2$, $T = 1$.
"""),
code("""mu, sigma_sq, init_sigma = 0.1, 0.16, 0.2
T, n_t = 1.0, 200
n_x = 201
res = opt.fokker_planck_constant(mu, sigma_sq, init_sigma,
-3.0, 3.0, n_x, T, n_t)
xs = np.array(res['x_grid'])
ts = np.array(res['time_grid'])
density = np.array(res['density']).reshape(n_t + 1, n_x)
p_final = density[-1]
# ground truth gaussienne
sig_eff = np.sqrt(sigma_sq * T + init_sigma ** 2)
analytic = np.exp(-(xs - mu * T) ** 2 / (2 * sig_eff ** 2))
analytic /= np.trapezoid(analytic, xs)
err = float(np.max(np.abs(p_final - analytic)))
mass = float(np.trapezoid(p_final, xs))
print(f\"Erreur sup |p_num - p_ana| = {err:.3e}\")
print(f\"Masse finale (≈ 1) = {mass:.4f}\")
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
axes[0].plot(xs, density[0], ':', label='p(0, x)', alpha=0.6)
axes[0].plot(xs, p_final, lw=2, label=f'p({T}, x) — schéma')
axes[0].plot(xs, analytic, '--', lw=1.5, label='gaussienne analytique')
axes[0].set_xlabel('x'); axes[0].set_ylabel('densité')
axes[0].set_title(\"Coupes initiale / finale\")
axes[0].legend()
im = axes[1].imshow(density.T, aspect='auto', origin='lower',
extent=[0, T, xs.min(), xs.max()],
cmap='inferno')
axes[1].set_xlabel('t'); axes[1].set_ylabel('x')
axes[1].set_title(\"Évolution spatio-temporelle\")
plt.colorbar(im, ax=axes[1], label='p(t, x)')
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** Erreur sup faible et masse $\\approx 1$.
**Lecture du graphique.** Gauche : pic initial fin (pointillé) qui
s'étale en gaussienne décalée vers la droite (drift $\\mu T = 0.1$).
Droite : carte chaleur de $p(t, x)$, qui s'élargit dans le temps.
**Conclusion.** Le solveur FokkerPlanck est validé sur le cas
gaussien.
"""),
md("""## Cellule 2 — Poisson 2D sur un carré (équation harmonique)
**Théorème (Poisson Dirichlet).** Pour $f$ régulière et conditions
nulles sur $\\partial\\Omega = [0,1]^2$, $-\\Delta u = f$ admet une
unique solution dans $H^1_0$.
**Équation pivot.** Si $f = 2\\pi^2 \\sin(\\pi x)\\sin(\\pi y)$ alors
$$u(x, y) = \\sin(\\pi x)\\sin(\\pi y).$$
**Démonstration.** Calcul direct : $-\\Delta u = 2\\pi^2 \\sin(\\pi x)
\\sin(\\pi y) = f$. $\\square$
**Ce que la cellule vérifie.** Le solveur SOR `poisson_2d_zero_boundary`
résout l'équation et l'erreur tend vers zéro.
"""),
code("""nx_grid = 41
xs = ys = np.linspace(0, 1, nx_grid)
X, Y = np.meshgrid(xs, ys, indexing='ij')
f = 2 * np.pi ** 2 * np.sin(np.pi * X) * np.sin(np.pi * Y)
res = opt.poisson_2d_zero_boundary(
f.flatten().tolist(), nx_grid, nx_grid,
0.0, 1.0, 0.0, 1.0,
)
u = np.array(res['u']).reshape(nx_grid, nx_grid)
u_exact = np.sin(np.pi * X) * np.sin(np.pi * Y)
err = float(np.max(np.abs(u - u_exact)))
print(f\"Itérations SOR : {res['iterations']}\")
print(f\"Résidu final : {res['residual']:.3e}\")
print(f\"Erreur sup : {err:.3e}\")
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
im0 = axes[0].imshow(u, origin='lower', extent=[0, 1, 0, 1], cmap='viridis')
axes[0].set_title('u numérique'); plt.colorbar(im0, ax=axes[0])
im1 = axes[1].imshow(u - u_exact, origin='lower',
extent=[0, 1, 0, 1], cmap='RdBu_r')
axes[1].set_title('erreur signée'); plt.colorbar(im1, ax=axes[1])
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** Erreur sup faible (< $10^{-2}$) ; résidu
SOR petit.
**Lecture du graphique.** Cloche centrée à hauteur $1$ ;
carte d'erreur quasi nulle, symétrique autour de zéro.
**Conclusion.** Le solveur elliptique est validé.
"""),
md("""## Cellule 3 — Exemple concret : diffusion thermique sur une plaque
**Modèle physique.** Plaque carrée $[0,1]^2$ chauffée par une source
gaussienne au centre, conditions de Dirichlet nulles sur le bord.
Équilibre régi par
$$-\\Delta u = q(x, y),\\qquad u\\big|_{\\partial\\Omega} = 0.$$
**Ce que la cellule vérifie.** Profil radial décroissant de température
sous source ponctuelle.
"""),
code("""nx_grid = 81
xs = ys = np.linspace(0, 1, nx_grid)
X, Y = np.meshgrid(xs, ys, indexing='ij')
q = 50.0 * np.exp(-((X - 0.5) ** 2 + (Y - 0.5) ** 2) / (2 * 0.05 ** 2))
res = opt.poisson_2d_zero_boundary(
q.flatten().tolist(), nx_grid, nx_grid,
0.0, 1.0, 0.0, 1.0,
)
u = np.array(res['u']).reshape(nx_grid, nx_grid)
print(f\"Température max au centre : {u.max():.3f}\")
print(f\"Température max au bord : {u[0, :].max():.2e}\")
print(f\"Itérations SOR : {res['iterations']}\")
fig, ax = plt.subplots(figsize=(6, 5))
im = ax.imshow(u, origin='lower', extent=[0, 1, 0, 1],
cmap='inferno', interpolation='bilinear')
ax.contour(X, Y, u, levels=8, colors='white', linewidths=0.5, alpha=0.6)
ax.set_title(\"Plaque chauffée par une source ponctuelle (équilibre)\")
ax.set_xlabel('x'); ax.set_ylabel('y')
plt.colorbar(im, ax=ax, label='température')
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** Température décroissante du centre vers le
bord (Dirichlet), profil radial visible.
**Lecture du graphique.** Iso-températures (lignes blanches) circulaires
autour de la source ; température nulle sur les bords.
**Conclusion.** Le même solveur Poisson 2D résout des problèmes
mathématiques académiques *et* des problèmes physiques concrets
(transfert thermique, électrostatique).
"""),
]
# ---------------------------------------------------------------------------
# 12 — Stochastic control (Pontryagin LQR)
# ---------------------------------------------------------------------------
NB12_TITLE = """# 12 — Stochastic control (Pontryagin LQR)
Doc page: [stochastic_control.rst](../../docs/source/algorithms/stochastic_control.rst).
"""
NB12_CELLS = [
md("""## Cellule 1 — LQR scalaire et équation de Riccati
**Théorème (Kalman, LQR).** Pour le système $\\dot x = ax + bu$ et le
coût
$$J = \\int_0^T (q x^2 + r u^2)\\,dt + s\\,x(T)^2,$$
le contrôle optimal est $u^*(t) = -(b/r)\\,P(t)\\,x(t)$ où $P(t)$
satisfait la Riccati rétrograde
$$-\\dot P = 2aP - (b^2/r) P^2 + q,\\qquad P(T) = s.$$
**Équation pivot (cas $a = b = q = r = 1$).** Le point fixe
stationnaire est $P^* = 1 + \\sqrt{2} \\approx 2.414$.
**Ce que la cellule vérifie.** Le primitive `pontryagin_lqr` retourne
$P(0)$ proche du point fixe pour $T = 5$.
"""),
code("""res = opt.pontryagin_lqr(
a=1.0, b=1.0, q=1.0, r=1.0, s_terminal=0.5,
x0=1.0, t_horizon=5.0, n_steps=400,
)
ts = np.array(res['time_grid'])
P = np.array(res['riccati'])
state = np.array(res['state'])
control = np.array(res['control'])
P_star = 1.0 + np.sqrt(2.0)
print(f\"P(0) numérique = {P[0]:.4f}\")
print(f\"P* analytique = {P_star:.4f}\")
print(f\"P(T) (terminal) = {P[-1]:.4f}\")
print(f\"Coût optimal = {res['cost']:.4f}\")
fig, axes = plt.subplots(1, 3, figsize=(13, 3.8))
axes[0].plot(ts, P, lw=2)
axes[0].axhline(P_star, ls='--', color='gray',
label=f'P* = {P_star:.3f}')
axes[0].set_xlabel('t'); axes[0].set_ylabel('P(t)')
axes[0].set_title(\"Riccati\"); axes[0].legend()
axes[1].plot(ts, state, lw=2, color='C2')
axes[1].set_xlabel('t'); axes[1].set_ylabel('x(t)')
axes[1].set_title(\"État optimal\")
ts_u = ts[:len(control)]
axes[2].plot(ts_u, control, lw=2, color='C3')
axes[2].set_xlabel('t'); axes[2].set_ylabel('u(t)')
axes[2].set_title(\"Commande optimale\")
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** $P(0) \\approx 1 + \\sqrt{2}$ ; l'état $x$
décroît rapidement vers $0$ ; la commande est proportionnelle à
$-P(t)\\,x(t)/r$.
**Lecture du graphique.** $P(t)$ plate sur $[0, T-\\epsilon]$ puis
descend vers $s = 0.5$ ; trajectoires d'état/commande lisses.
**Conclusion.** Le LQR scalaire est validé contre le point fixe
analytique.
"""),
md("""## Cellule 2 — Étude paramétrique : sensibilité au coût terminal
**Théorème.** Lorsque $T \\to \\infty$, $P(0)$ tend vers le point fixe
indépendamment de $s$.
**Équation pivot.** $P^* = (a + \\sqrt{a^2 + b^2 q / r}) \\cdot r / b^2$.
Pour $a = b = q = r = 1$, $P^* = 1 + \\sqrt{2}$.
**Ce que la cellule vérifie.** Sweep sur plusieurs valeurs de $s$ et
de $T$ : $P(0)$ converge vers $P^*$.
"""),
code("""s_values = [0.1, 0.5, 2.0, 5.0]
T_values = [0.5, 1.0, 2.0, 5.0, 10.0]
P_star = 1.0 + np.sqrt(2.0)
fig, ax = plt.subplots()
for s in s_values:
p0s = []
for T_ in T_values:
r = opt.pontryagin_lqr(1.0, 1.0, 1.0, 1.0, s, 1.0, T_, 200)
p0s.append(r['riccati'][0])
ax.plot(T_values, p0s, 'o-', lw=2, label=f's = {s}')
print(f\"s = {s:4.1f} : P(0) à T=10 = {p0s[-1]:.4f}\")
ax.axhline(P_star, ls='--', color='black',
label=f'P* = {P_star:.3f}')
ax.set_xlabel('horizon T'); ax.set_ylabel('P(0)')
ax.set_title(\"Convergence vers le point fixe Riccati\")
ax.legend()
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** Toutes les courbes convergent vers
$P^* \\approx 2.414$ pour $T$ grand.
**Lecture du graphique.** L'effet du terminal $s$ s'estompe quand $T$
augmente.
**Conclusion.** En horizon long, le coût terminal devient négligeable.
"""),
md("""## Cellule 3 — Exemple concret : stabilisation d'un pendule inversé
**Modèle physique.** Pendule inversé linéarisé autour de la verticale
$\\ddot\\theta = (g/\\ell)\\theta + (1/m\\ell^2) u$. Pour le mode
sur-amorti $(\\dot\\theta \\equiv 0)$ on retient l'équation scalaire
$\\dot\\theta = a\\theta + bu$ avec $a = g/\\ell$, $b = 1/(m\\ell^2)$.
**Équation pivot (Riccati).** $P$ vérifie $-\\dot P = 2aP - (b^2/r)P^2 + q$.
**Ce que la cellule vérifie.** Le contrôleur LQR ramène l'angle
initial $0.3$ rad vers $0$.
"""),
code("""g, ell, m = 9.81, 1.0, 1.0
a, b = g / ell, 1.0 / (m * ell ** 2)
res = opt.pontryagin_lqr(a, b, q=10.0, r=1.0, s_terminal=1.0,
x0=0.3, t_horizon=5.0, n_steps=500)
ts = np.array(res['time_grid'])
theta = np.array(res['state'])
u = np.array(res['control'])
print(f\"Angle initial : {theta[0]:.3f} rad ({np.degrees(theta[0]):.1f}°)\")
print(f\"Angle final : {theta[-1]:.3e} rad\")
print(f\"Effort max : {np.abs(u).max():.3f}\")
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].plot(ts, theta, lw=2, color='C3', label=r'$\\theta(t)$')
axes[0].axhline(0, ls='--', color='gray', alpha=0.6)
axes[0].set_xlabel('t (s)'); axes[0].set_ylabel(r'$\\theta$ (rad)')
axes[0].set_title(\"Stabilisation du pendule inversé\")
axes[0].legend()
ts_u = ts[:len(u)]
axes[1].plot(ts_u, u, lw=2, color='C2', label='u(t)')
axes[1].set_xlabel('t (s)'); axes[1].set_ylabel('u (couple)')
axes[1].set_title(\"Couple appliqué (LQR)\")
axes[1].legend()
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** $\\theta \\to 0$ rapidement, effort borné.
**Lecture du graphique.** Décroissance exponentielle de l'angle ;
couple initial fort puis amorti.
**Conclusion.** Le primitive transforme un système naturellement
instable ($a = 9.81 > 0$) en système stable, illustration directe
du contrôle optimal.
"""),
]
# ---------------------------------------------------------------------------
# 13 — Quadratic impact control
# ---------------------------------------------------------------------------
NB13_TITLE = """# 13 — Quadratic-impact control
Doc page: [quadratic_impact_control.rst](../../docs/source/algorithms/quadratic_impact_control.rst).
"""
NB13_CELLS = [
md("""## Cellule 1 — Riccati générique sur un horizon fini
**Théorème (HJB quadratique 1-D).** Pour $dq_t = u_t\\,dt + \\sigma\\,dW_t$
et coût $L(q, u) = \\gamma u^2 + \\varphi q^2$, $g(q) = A q^2$, la
fonction valeur est $V(t, q) = h(t) q^2$ avec
$$h'(t) = h(t)^2 / \\gamma - \\varphi,\\qquad h(T) = A.$$
Le feedback optimal est $u^*(t) = -(h(t)/\\gamma)\\,q(t)$.
**Équation pivot.** Pour $\\gamma = \\varphi = A = 1$, le point fixe
est $h^* = 1$ (car $h^2 - 1 = 0 \\Rightarrow h = 1$).
**Ce que la cellule vérifie.** Le primitive
`quadratic_impact_control_py(gamma=1, phi=1, A=1, T, n)` retourne
$h(t) \\equiv 1$ et donc un gain de feedback unitaire.
"""),
code("""res = opt.quadratic_impact_control_py(gamma=1.0, phi=1.0,
a_terminal=1.0,
t_horizon=0.5, n_steps=500)
ts = np.array(res['time_grid'])
h = np.array(res['h'])
k = np.array(res['feedback_gain'])
err = float(np.max(np.abs(h - 1.0)))
print(f\"max |h(t) - 1| = {err:.3e}\")
print(f\"Feedback gain : k(0) = {k[0]:.4f}, k(T) = {k[-1]:.4f}\")
fig, ax = plt.subplots()
ax.plot(ts, h, lw=2, label=r'$h(t)$')
ax.plot(ts, k, '--', lw=2, label=r'$k(t) = h(t)/\\gamma$')
ax.axhline(1.0, ls=':', color='gray', alpha=0.6, label='point fixe = 1')
ax.set_xlabel('t'); ax.set_ylabel('value')
ax.set_title(\"Riccati quadratique scalaire\")
ax.legend()
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** $h(t) \\equiv 1$ à précision machine.
**Lecture du graphique.** Lignes plates à 1.
**Conclusion.** Le primitive est validé sur le point fixe analytique.
"""),
md("""## Cellule 2 — Étude paramétrique : pénalisation $\\varphi$
**Théorème.** Le point fixe $h^* = \\sqrt{\\gamma \\varphi}$ croît
avec $\\varphi$.
**Équation pivot.**
$$h^* = \\sqrt{\\gamma \\varphi},
\\qquad k^* = h^*/\\gamma = \\sqrt{\\varphi/\\gamma}.$$
**Ce que la cellule vérifie.** Sweep $\\varphi \\in \\{0.1, 1, 4, 10\\}$ :
$h(0)$ s'aligne sur $\\sqrt{\\varphi}$ pour $\\gamma = 1$.
"""),
code("""phis = [0.1, 1.0, 4.0, 10.0]
fig, ax = plt.subplots()
for phi in phis:
r = opt.quadratic_impact_control_py(1.0, phi, np.sqrt(phi),
t_horizon=2.0, n_steps=500)
h_arr = np.array(r['h'])
h_star = np.sqrt(phi)
ax.plot(r['time_grid'], h_arr,
label=fr'$\\varphi = {phi}$, $h^*={h_star:.2f}$')
print(f\"phi={phi:5.2f} : h(0) = {h_arr[0]:.4f}, h* = {h_star:.4f}\")
ax.set_xlabel('t'); ax.set_ylabel('h(t)')
ax.set_title(r'Sensibilité de $h$ au coefficient $\\varphi$')
ax.legend()
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** Plus $\\varphi$ est grand, plus le gain de
feedback est élevé.
**Lecture du graphique.** Quatre lignes plates à des hauteurs
$\\sqrt{\\varphi}$.
**Conclusion.** Le compromis état/contrôle est gouverné par le
ratio $\\varphi/\\gamma$.
"""),
md("""## Cellule 3 — Exemple concret : régulation autour d'un set-point
**Modèle.** En boucle fermée $\\dot q = -k q$ avec $k = h/\\gamma$,
la dynamique est $q(t) = q_0 e^{-kt}$. Cas pratique : régulateur
thermique scalaire qui ramène la température vers $0$ (écart à la
consigne).
**Équation pivot.**
$$q(t) = q_0\\,e^{-(h^*/\\gamma)\\,t}.$$
**Ce que la cellule vérifie.** Pour différents $\\varphi$, on intègre
$\\dot q = -k(t) q$ et on observe la vitesse de retour vers $0$.
"""),
code("""T, n_steps = 3.0, 600
dt = T / n_steps
q0 = 1.0
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
for phi in [0.25, 1.0, 4.0]:
r = opt.quadratic_impact_control_py(1.0, phi, np.sqrt(phi),
t_horizon=T, n_steps=n_steps)
k = np.array(r['feedback_gain'])
q = np.zeros(n_steps + 1); q[0] = q0
for i in range(n_steps):
q[i + 1] = q[i] - dt * k[i] * q[i]
ts = np.array(r['time_grid'])
axes[0].plot(ts, q, lw=2, label=fr'$\\varphi = {phi}$')
axes[1].plot(ts, k, lw=2, label=fr'$\\varphi = {phi}$')
print(f\"phi={phi:.2f} : q(T) = {q[-1]:.4f}\")
axes[0].set_xlabel('t'); axes[0].set_ylabel('q(t)')
axes[0].set_title(\"Écart à la consigne\")
axes[0].legend()
axes[1].set_xlabel('t'); axes[1].set_ylabel('k(t) (gain)')
axes[1].set_title(\"Gain de feedback\")
axes[1].legend()
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** Plus $\\varphi$ est grand, plus $q$ retourne
rapidement à $0$.
**Lecture du graphique.** Décroissance exponentielle visible ; gain
plat (régime stationnaire de la Riccati).
**Conclusion.** Le primitive fournit le gain optimal pour tout
problème linéaire-quadratique scalaire à pénalité d'impact.
"""),
]
# ---------------------------------------------------------------------------
# 14 — McKean-Vlasov
# ---------------------------------------------------------------------------
NB14_TITLE = """# 14 — Mean-reverting McKeanVlasov
Doc page: [mckean_vlasov.rst](../../docs/source/algorithms/mckean_vlasov.rst).
"""
NB14_CELLS = [
md("""## Cellule 1 — Conservation de la moyenne
**Théorème (McKean 1966).** Pour la dynamique de champ moyen
$$dX_t^i = \\theta\\,(\\bar X_t - X_t^i)\\,dt + \\sigma\\,dW_t^i,
\\qquad \\bar X_t = \\frac{1}{N}\\sum_j X_t^j,$$
la moyenne empirique est conservée en espérance,
$\\mathbb{E}[\\bar X_t] = \\bar X_0$.
**Équation pivot.** $\\mathbb{E}[\\bar X_t] = \\bar X_0$ pour tout $t$.
**Démonstration.** Sommer l'EDS sur $i$ : la dérive interne s'annule
(moyenne — individu). $\\square$
**Ce que la cellule vérifie.** `mean_reverting_mckean_vlasov` préserve
la moyenne et concentre la variance.
"""),
code("""N, T, n_steps = 500, 1.0, 200
theta, sigma = 1.5, 0.3
x0 = np.linspace(-1.0, 1.0, N).tolist() # mean = 0
res = opt.mean_reverting_mckean_vlasov(x0, theta, sigma, n_steps, T, 42)
n_t = res['n_steps']
n_part = res['n_particles']
paths = np.array(res['paths_flat']).reshape(n_t, n_part)
ts = np.array(res['time_grid'])
mean = paths.mean(axis=1)
var = paths.var(axis=1)
print(f\"n_steps stocké : {n_t} (snapshots)\")
print(f\"n_particles : {n_part}\")
print(f\"Moyenne initiale : {mean[0]:.3e}\")
print(f\"Moyenne finale : {mean[-1]:.3e}\")
print(f\"Variance init : {var[0]:.3f}\")
print(f\"Variance finale : {var[-1]:.3f}\")
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
for i in range(0, n_part, 25):
axes[0].plot(ts, paths[:, i], alpha=0.4, lw=0.7)
axes[0].plot(ts, mean, 'k-', lw=2, label='moyenne empirique')
axes[0].set_xlabel('t'); axes[0].set_ylabel(r'$X_t^i$')
axes[0].set_title(\"Trajectoires McKeanVlasov\")
axes[0].legend()
axes[1].plot(ts, var, lw=2, color='C2')
axes[1].set_xlabel('t'); axes[1].set_ylabel(r'Var($X_t$)')
axes[1].set_title(\"Variance empirique\")
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** $|\\bar X_t - 0|$ très petit, variance qui
décroît vers une borne stationnaire.
**Lecture du graphique.** Faisceau qui se contracte autour de la
moyenne ; variance qui diminue.
**Conclusion.** Le solveur reproduit la concentration mean-field.
"""),
md("""## Cellule 2 — Exemple concret : dynamique d'opinion polarisée
**Modèle.** Modèle de DeGroot continu : opinions initialement
bimodales (deux groupes opposés). La dynamique de champ moyen
détruit progressivement la polarisation.
**Équation pivot.**
$$dX_t^i = \\theta\\,(\\bar X_t - X_t^i)\\,dt + \\sigma\\,dW_t^i.$$
**Ce que la cellule vérifie.** Une distribution initiale bimodale
fusionne en distribution unimodale centrée sur $\\bar X_0$.
"""),
code("""rng = np.random.default_rng(7)
N = 600
half = N // 2
x0 = np.concatenate([
rng.normal(-1.0, 0.2, half),
rng.normal(+1.0, 0.2, N - half),
]).tolist()
res = opt.mean_reverting_mckean_vlasov(x0, theta=2.0, sigma=0.15,
n_steps=400, t_horizon=2.0, seed=11)
n_t = res['n_steps']
n_part = res['n_particles']
paths = np.array(res['paths_flat']).reshape(n_t, n_part)
mid = n_t // 2
print(f\"Variance t=0 : {paths[0].var():.3f}\")
print(f\"Variance t=mid : {paths[mid].var():.3f}\")
print(f\"Variance t=T : {paths[-1].var():.3f}\")
fig, axes = plt.subplots(1, 3, figsize=(13, 3.8))
for ax, idx, label in zip(axes, [0, mid, -1], ['t=0', 't=T/2', 't=T']):
ax.hist(paths[idx], bins=40, density=True,
color='C0', edgecolor='white', alpha=0.85)
ax.set_title(f\"Distribution {label}\")
ax.set_xlabel('opinion'); ax.set_ylabel('densité')
ax.set_xlim(-2, 2)
fig.suptitle(\"Convergence d'une population polarisée vers le consensus\",
fontsize=12, y=1.02)
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** L'histogramme bimodal initial fusionne en un
pic unique centré à $0$.
**Lecture du graphique.** Trois snapshots montrent la fusion
progressive des deux modes.
**Conclusion.** L'attraction par la moyenne (mean-field) détruit la
polarisation initiale en temps fini — illustration du théorème du
consensus dans les dynamiques de DeGroot continues.
"""),
]
# ---------------------------------------------------------------------------
# 15 — Agent-based consensus
# ---------------------------------------------------------------------------
NB15_TITLE = """# 15 — Agent-based consensus dynamics
Doc page: [agent_based.rst](../../docs/source/algorithms/agent_based.rst).
"""
NB15_CELLS = [
md("""## Cellule 1 — Convergence vers le consensus
**Théorème (consensus DeGroot, version $\\alpha$-pondérée).** Pour la
dynamique discrète
$$X^i_{k+1} = (1 - \\alpha)\\,X^i_k + \\alpha\\,\\bar X_k + \\sigma\\,\\varepsilon_k^i,$$
la moyenne $\\bar X_k$ est conservée en espérance et la variance
décroît à taux $(1 - \\alpha)^2$ par pas (pour $\\sigma = 0$).
**Équation pivot.**
$$\\mathbb{E}[\\text{Var}(X_k)] = (1 - \\alpha)^{2k}\\,\\text{Var}(X_0).$$
**Démonstration.** Récurrence linéaire : la composante hors moyenne
est multipliée par $(1-\\alpha)$ à chaque pas. $\\square$
**Ce que la cellule vérifie.** `consensus_dynamics(initial, alpha,
noise_sigma, n_steps, seed)` reproduit la décroissance géométrique.
"""),
code("""N, alpha, sigma, n_steps = 50, 0.3, 0.0, 60
rng = np.random.default_rng(1)
x0 = rng.uniform(-2, 2, N).tolist()
res = opt.consensus_dynamics(x0, alpha=alpha, noise_sigma=sigma,
n_steps=n_steps, seed=0)
states = np.array(res['states_flat']).reshape(n_steps + 1, N)
mean_traj = np.array(res['mean_trajectory'])
variances = states.var(axis=1)
print(f\"Moyenne initiale : {np.mean(x0):.3f}\")
print(f\"Moyenne finale : {mean_traj[-1]:.3f}\")
print(f\"Var init / final : {variances[0]:.3f} / {variances[-1]:.3e}\")
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
for i in range(N):
axes[0].plot(states[:, i], lw=0.8, alpha=0.5)
axes[0].plot(mean_traj, 'k-', lw=2, label='moyenne')
axes[0].set_xlabel('itération k'); axes[0].set_ylabel(r'$X_k^i$')
axes[0].set_title(f\"Consensus (α = {alpha}, σ = {sigma})\")
axes[0].legend()
axes[1].semilogy(variances, lw=2, label='variance empirique')
axes[1].semilogy(variances[0] * (1 - alpha) ** (2 * np.arange(n_steps + 1)),
':', label=r'$(1-\\alpha)^{2k}$ Var$(X_0)$')
axes[1].set_xlabel('itération k'); axes[1].set_ylabel(r'Var($X_k$)')
axes[1].set_title(\"Décroissance géométrique\")
axes[1].legend()
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** Variance qui décroît exponentiellement.
**Lecture du graphique.** Faisceau qui se concentre vers la moyenne
(ligne noire) ; pente log-linéaire conforme à la prédiction.
**Conclusion.** Le primitive est validé sur la convergence
géométrique.
"""),
md("""## Cellule 2 — Effet du bruit
**Théorème.** Lorsque $\\sigma > 0$, la variance ne tombe pas à zéro
mais se stabilise à un niveau d'équilibre proportionnel à $\\sigma^2$.
**Équation pivot.** Variance asymptotique croissante en $\\sigma^2$.
**Ce que la cellule vérifie.** Sweep sur $\\sigma \\in \\{0.05, 0.1,
0.2\\}$ pour $\\alpha = 0.3$.
"""),
code("""N, alpha, n_steps = 80, 0.3, 200
rng = np.random.default_rng(11)
x0 = rng.uniform(-2, 2, N).tolist()
fig, ax = plt.subplots()
for sigma in [0.05, 0.1, 0.2]:
r = opt.consensus_dynamics(x0, alpha, sigma, n_steps, seed=42)
states = np.array(r['states_flat']).reshape(n_steps + 1, N)
var = states.var(axis=1)
ax.plot(var, label=fr'$\\sigma = {sigma}$')
print(f\"σ = {sigma:.2f} : Var(K) numérique = {var[-1]:.4f}\")
ax.set_xlabel('itération k'); ax.set_ylabel(r'Var($X_k$)')
ax.set_title(\"Variance asymptotique vs bruit\")
ax.legend()
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** Variance qui plateauise à un niveau
croissant avec $\\sigma^2$.
**Lecture du graphique.** Trois courbes décroissantes qui se
stabilisent à des paliers proportionnels à $\\sigma^2$.
**Conclusion.** Le compromis bruit / convergence est gouverné par
$\\alpha$ et $\\sigma$.
"""),
md("""## Cellule 3 — Exemple concret : émergence d'une décision collective
**Modèle.** $N = 100$ agents au sein d'un comité décident d'un
nombre (par exemple un score entre $-1$ et $+1$). Chaque agent
ajuste sa position vers la moyenne avec un peu de bruit.
**Équation pivot.**
$$X^i_{k+1} = (1 - \\alpha) X^i_k + \\alpha \\bar X_k + \\sigma \\varepsilon^i_k.$$
**Ce que la cellule vérifie.** Visualisation : densité des
opinions au cours du temps converge vers une distribution unimodale.
"""),
code("""N = 100
n_steps = 80
alpha, sigma = 0.2, 0.04
rng = np.random.default_rng(7)
# distribution initiale : trois clusters
x0 = np.concatenate([
rng.normal(-0.7, 0.15, 35),
rng.normal(0.0, 0.10, 35),
rng.normal(+0.7, 0.15, 30),
]).tolist()
r = opt.consensus_dynamics(x0, alpha, sigma, n_steps, seed=0)
states = np.array(r['states_flat']).reshape(n_steps + 1, N)
mean_traj = np.array(r['mean_trajectory'])
print(f\"Moyenne initiale : {np.mean(x0):.3f}\")
print(f\"Moyenne finale : {mean_traj[-1]:.3f}\")
fig, axes = plt.subplots(1, 2, figsize=(12, 4.2))
im = axes[0].imshow(states.T, aspect='auto', cmap='coolwarm',
interpolation='nearest', origin='lower',
extent=[0, n_steps, 0, N])
axes[0].set_xlabel('itération k')
axes[0].set_ylabel('agent i')
axes[0].set_title(\"Évolution des opinions individuelles\")
plt.colorbar(im, ax=axes[0], label='X_k^i')
for k, color in zip([0, 20, n_steps], ['C0', 'C2', 'C3']):
axes[1].hist(states[k], bins=20, alpha=0.55, color=color,
density=True, label=f'k={k}', edgecolor='white')
axes[1].set_xlabel('opinion'); axes[1].set_ylabel('densité')
axes[1].set_title(\"Distribution à différents instants\")
axes[1].legend()
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** Les trois clusters initiaux fusionnent en
distribution unimodale concentrée près de $\\bar X_0$.
**Lecture du graphique.** Gauche : la heatmap montre l'horizon de
fusion. Droite : trois distributions superposées (initiale tri-modale,
intermédiaire, finale unimodale).
**Conclusion.** Le primitive `consensus_dynamics` modélise toute
dynamique de moyennage par interaction (vote pondéré, opinion
publique, scoring collectif).
"""),
]
# ---------------------------------------------------------------------------
# 16 — Robust drift
# ---------------------------------------------------------------------------
NB16_TITLE = """# 16 — Robust drift estimation (Huber)
Doc page: [robust_drift.rst](../../docs/source/algorithms/robust_drift.rst).
"""
NB16_CELLS = [
md("""## Cellule 1 — Récupération du drift d'un OU sous bruit gaussien
**Théorème.** Pour le processus d'OrnsteinUhlenbeck discret
$$x_{k+1} = x_k + (a + b x_k)\\,\\Delta t + \\sigma\\,\\varepsilon_k,$$
l'estimateur de Huber via IRLS converge vers $(a, b)$ pour bruit
symétrique.
**Équation pivot (lien avec OU continu).** $dX_t = (a + b X_t) dt
+ \\sigma dW_t$ ; en posant $a = \\theta\\mu$, $b = -\\theta$, on
récupère le mean-reversion $dX_t = \\theta(\\mu - X_t)dt + \\sigma dW_t$.
**Ce que la cellule vérifie.** $\\theta = 2$, $\\mu = 1$ donc
$a_\\text{vrai} = 2$, $b_\\text{vrai} = -2$. L'estimateur retrouve
ces valeurs sur $N = 1000$ observations.
"""),
code("""rng = np.random.default_rng(0)
theta_true, mu_true, sigma = 2.0, 1.0, 0.2
N = 1000
dt = 0.01
x = np.zeros(N)
x[0] = 0.5
for k in range(N - 1):
x[k + 1] = x[k] + theta_true * (mu_true - x[k]) * dt + \\
sigma * np.sqrt(dt) * rng.standard_normal()
res = opt.robust_drift(x.tolist(), dt, huber_delta=1.345)
a_hat, b_hat = res['a'], res['b']
a_true, b_true = theta_true * mu_true, -theta_true
print(f\"a vrai / estimé : {a_true:.3f} / {a_hat:.3f}\")
print(f\"b vrai / estimé : {b_true:.3f} / {b_hat:.3f}\")
print(f\"theta_hat = {-b_hat:.3f}, mu_hat = {a_hat / -b_hat:.3f}\")
print(f\"itérations IRLS : {res['iterations']}\")
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
ts = np.arange(N) * dt
axes[0].plot(ts, x, lw=0.8)
axes[0].axhline(mu_true, ls='--', color='gray', label=fr'$\\mu = {mu_true}$')
axes[0].set_xlabel('t'); axes[0].set_ylabel('x(t)')
axes[0].set_title(\"Trajectoire OU simulée\")
axes[0].legend()
dx = (x[1:] - x[:-1]) / dt
axes[1].scatter(x[:-1], dx, s=4, alpha=0.4, label='observations')
xs_lin = np.linspace(x.min(), x.max(), 50)
axes[1].plot(xs_lin, a_true + b_true * xs_lin, 'k--',
lw=1.5, label='vrai drift')
axes[1].plot(xs_lin, a_hat + b_hat * xs_lin, 'C3-',
lw=2, label='Huber estimé')
axes[1].set_xlabel('x'); axes[1].set_ylabel(r'$\\Delta x / \\Delta t$')
axes[1].set_title(\"Régression robuste sur le drift\")
axes[1].legend()
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** $\\hat a \\approx 2.0$, $\\hat b \\approx -2.0$.
**Lecture du graphique.** Gauche : trajectoire qui oscille autour de
$\\mu = 1$. Droite : nuage des incréments avec deux droites
superposées (vraie et estimée).
**Conclusion.** Sur bruit gaussien, Huber se comporte comme OLS
(efficacité ~95 %).
"""),
md("""## Cellule 2 — Robustesse face à un bruit de Cauchy
**Théorème (consistance de Huber sous queues lourdes).** Pour
$\\varepsilon_k \\sim \\text{Cauchy}$, OLS *diverge* (variance
infinie) tandis que Huber reste consistant.
**Équation pivot.**
$$\\hat\\theta_\\text{Huber} \\xrightarrow{a.s.} \\theta_0,
\\qquad \\hat\\theta_\\text{OLS} \\nrightarrow \\theta_0
\\text{ (Cauchy)}.$$
**Ce que la cellule vérifie.** On compare Huber vs OLS sur 30 jeux
synthétiques OU avec bruit de Cauchy injecté.
"""),
code("""rng = np.random.default_rng(123)
theta_true, mu_true = 2.0, 1.0
sigma_cauchy = 0.05
N = 1000; dt = 0.01
n_runs = 30
a_true, b_true = theta_true * mu_true, -theta_true
ols_errs, huber_errs = [], []
for _ in range(n_runs):
x = np.zeros(N); x[0] = 0.5
for k in range(N - 1):
eps = rng.standard_cauchy() * sigma_cauchy
x[k + 1] = x[k] + theta_true * (mu_true - x[k]) * dt + eps * np.sqrt(dt)
Xmat = np.column_stack([np.ones(N - 1), x[:-1]])
yvec = (x[1:] - x[:-1]) / dt
th_ols, *_ = np.linalg.lstsq(Xmat, yvec, rcond=None)
r = opt.robust_drift(x.tolist(), dt, huber_delta=1.345)
th_h = np.array([r['a'], r['b']])
ols_errs.append(np.linalg.norm(th_ols - np.array([a_true, b_true])))
huber_errs.append(np.linalg.norm(th_h - np.array([a_true, b_true])))
ols_errs = np.array(ols_errs); huber_errs = np.array(huber_errs)
print(f\"OLS : médiane = {np.median(ols_errs):.3f}, max = {ols_errs.max():.2f}\")
print(f\"Huber : médiane = {np.median(huber_errs):.3f}, max = {huber_errs.max():.2f}\")
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].boxplot([ols_errs, huber_errs], labels=['OLS', 'Huber'])
axes[0].set_ylabel(r'$\\|\\hat\\theta - \\theta_0\\|$')
axes[0].set_yscale('log')
axes[0].set_title(f\"{n_runs} runs OU + bruit Cauchy\")
axes[1].hist(np.clip(ols_errs, 0, 10), bins=20,
alpha=0.5, label='OLS', color='C3')
axes[1].hist(huber_errs, bins=20, alpha=0.7, label='Huber', color='C0')
axes[1].set_xlabel(r'$\\|\\hat\\theta - \\theta_0\\|$')
axes[1].set_ylabel('fréquence')
axes[1].set_title(\"Distribution des erreurs\")
axes[1].legend()
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** Huber médiane bien inférieure à OLS médiane.
**Lecture du graphique.** Gauche : boxplots log, Huber serré.
Droite : histogramme — OLS dispersé, Huber concentré près de $0$.
**Conclusion.** L'estimateur robuste est indispensable dès que les
queues s'éloignent du gaussien (mesures expérimentales bruitées,
signaux de capteurs aberrants).
"""),
]
# ---------------------------------------------------------------------------
# 17 — Generative calibration hooks (MMD)
# ---------------------------------------------------------------------------
NB17_TITLE = """# 17 — Generative calibration hooks (MMD)
Doc page: [generative_calibration_hooks.rst](../../docs/source/algorithms/generative_calibration_hooks.rst).
"""
NB17_CELLS = [
md("""## Cellule 1 — MMD nulle entre échantillons identiques
**Théorème (Gretton et al. 2012).** Pour le noyau gaussien
$k(x, y) = \\exp(-(x-y)^2 / 2\\sigma^2)$, la MMD au carré empirique
entre $X = (x_i)$ et $Y = (y_j)$ est
$$\\widehat{\\text{MMD}}^2(X, Y) = \\frac{1}{m^2}\\sum_{ij} k(x_i, x_j)
+ \\frac{1}{n^2}\\sum_{ij} k(y_i, y_j)
- \\frac{2}{mn}\\sum_{ij} k(x_i, y_j).$$
**Équation pivot.** $X = Y \\Rightarrow \\widehat{\\text{MMD}}^2 = 0$.
**Démonstration.** Les trois sommes coïncident terme à terme. $\\square$
**Ce que la cellule vérifie.** `mmd_gaussian(X, X, sigma)` retourne $0$
à précision machine.
"""),
code("""rng = np.random.default_rng(0)
X = rng.standard_normal(200).tolist()
m = float(opt.mmd_gaussian(X, X, sigma=1.0))
print(f\"MMD(X, X) = {m:.3e} (attendu : 0)\")
fig, ax = plt.subplots()
ax.hist(X, bins=30, density=True, color='C0',
edgecolor='white', alpha=0.85)
ax.set_title(r'Échantillon $X = Y$')
ax.set_xlabel('x'); ax.set_ylabel('densité')
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** $\\sim 0$.
**Lecture du graphique.** Histogramme gaussien standard.
**Conclusion.** L'auto-MMD nulle est validée.
"""),
md("""## Cellule 2 — MMD entre gaussienne et Laplace
**Théorème.** Deux distributions de moyenne et variance identiques
mais formes différentes ont une MMD strictement positive.
**Équation pivot.**
$$\\text{MMD}^2(\\mathcal{N}(0, 1), \\text{Laplace}(0, 1/\\sqrt{2})) > 0.$$
**Ce que la cellule vérifie.** Sweep en $\\sigma$ : la MMD est
maximale autour de $\\sigma$ commensurable à l'échelle des
distributions.
"""),
code("""rng = np.random.default_rng(2)
n = 500
X = rng.standard_normal(n).tolist() # N(0, 1)
Y = rng.laplace(0.0, 1.0 / np.sqrt(2.0), n).tolist() # même variance
sigmas = np.geomspace(0.1, 10.0, 25)
mmds = [float(opt.mmd_gaussian(X, Y, sigma=s)) for s in sigmas]
print(f\"MMD min : {min(mmds):.3e}, MMD max : {max(mmds):.3e}\")
print(f\"σ optimal : {sigmas[int(np.argmax(mmds))]:.2f}\")
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
xs = np.linspace(-5, 5, 400)
axes[0].hist(X, bins=40, density=True, alpha=0.5,
label=r'$\\mathcal{N}(0,1)$', color='C0')
axes[0].hist(Y, bins=40, density=True, alpha=0.5,
label='Laplace', color='C3')
axes[0].plot(xs, np.exp(-xs**2/2)/np.sqrt(2*np.pi),
'C0--', lw=1.5)
axes[0].plot(xs, np.exp(-np.abs(xs)*np.sqrt(2))*np.sqrt(2)/2,
'C3--', lw=1.5)
axes[0].set_xlabel('x'); axes[0].set_ylabel('densité')
axes[0].set_title(\"Densités comparées\"); axes[0].legend()
axes[1].semilogx(sigmas, mmds, 'o-', lw=2, color='C2')
axes[1].set_xlabel(r'bandwidth $\\sigma$')
axes[1].set_ylabel(r'$\\widehat{\\text{MMD}}^2$')
axes[1].set_title(\"Sensibilité à la bandwidth\")
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** MMD strictement positive, courbe en cloche.
**Lecture du graphique.** Gauche : la Laplace est plus pointue ;
droite : MMD maximale lorsque $\\sigma \\sim 1$.
**Conclusion.** Le choix de bandwidth est crucial ; règle pratique :
$\\sigma \\approx \\text{médiane des distances}$.
"""),
md("""## Cellule 3 — Exemple concret : MMD entre mélange et gaussienne
**Modèle.** $P = \\mathcal{N}(0, 1)$ vs
$Q_\\mu = \\frac{1}{2}\\mathcal{N}(-\\mu, 1) + \\frac{1}{2}\\mathcal{N}(+\\mu, 1)$.
La MMD doit *croître* avec $\\mu$.
**Équation pivot.**
$$\\text{MMD}^2(P, Q_\\mu) \\xrightarrow[\\mu \\to 0]{} 0,
\\qquad \\nearrow \\text{ en } \\mu.$$
**Ce que la cellule vérifie.** Pour $\\mu \\in [0, 2.5]$, la courbe
MMD est croissante.
"""),
code("""rng = np.random.default_rng(11)
n = 400
P = rng.standard_normal(n).tolist()
mus = np.linspace(0.0, 2.5, 11)
mmd_vals = []
for mu in mus:
half = n // 2
Q = np.concatenate([
rng.standard_normal(half) - mu,
rng.standard_normal(n - half) + mu,
]).tolist()
mmd_vals.append(float(opt.mmd_gaussian(P, Q, sigma=1.0)))
for mu, m in zip(mus, mmd_vals):
print(f\"μ = {mu:.2f} -> MMD² = {m:.3e}\")
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
axes[0].plot(mus, mmd_vals, 'o-', lw=2, color='C2')
axes[0].set_xlabel(r'séparation $\\mu$')
axes[0].set_ylabel(r'$\\widehat{\\text{MMD}}^2$')
axes[0].set_title(\"MMD croissante avec la séparation\")
mu_show = mus[-1]
half = n // 2
Q_show = np.concatenate([
rng.standard_normal(half) - mu_show,
rng.standard_normal(n - half) + mu_show,
])
axes[1].hist(P, bins=30, density=True, alpha=0.5,
label=r'P = $\\mathcal{N}(0,1)$', color='C0')
axes[1].hist(Q_show, bins=30, density=True, alpha=0.5,
label=fr'Q (μ = {mu_show:.1f})', color='C3')
axes[1].set_xlabel('x'); axes[1].set_ylabel('densité')
axes[1].set_title(\"P vs Q (mélange séparé)\")
axes[1].legend()
fig.tight_layout(); plt.show()
"""),
md("""**Résultat attendu.** MMD croît monotone en $\\mu$.
**Lecture du graphique.** Gauche : courbe croissante. Droite : la
gaussienne unique vs le mélange bimodal sont visuellement
distincts à $\\mu = 2.5$.
**Conclusion.** MMD avec noyau gaussien est un détecteur efficace de
différence distributionnelle, applicable à la calibration de modèles
génératifs (GAN, normalizing flows) en boucle d'apprentissage.
"""),
]
# ---------------------------------------------------------------------------
NOTEBOOKS = [
("10_bsde.ipynb", NB10_TITLE, NB10_CELLS),
("11_pde.ipynb", NB11_TITLE, NB11_CELLS),
("12_stochastic_control.ipynb", NB12_TITLE, NB12_CELLS),
("13_quadratic_impact.ipynb", NB13_TITLE, NB13_CELLS),
("14_mckean_vlasov.ipynb", NB14_TITLE, NB14_CELLS),
("15_agent_based.ipynb", NB15_TITLE, NB15_CELLS),
("16_robust_drift.ipynb", NB16_TITLE, NB16_CELLS),
("17_generative_calibration.ipynb", NB17_TITLE, NB17_CELLS),
]
def main() -> None:
failures: list[str] = []
for name, title, cells in NOTEBOOKS:
path = write_notebook(name, title, cells)
print(f" written {path.name} ({len(cells) + 2} cells)")
rc = subprocess.run(
[
"jupyter", "nbconvert",
"--to", "notebook",
"--execute",
"--inplace",
"--ExecutePreprocessor.kernel_name=rhftlab",
"--ExecutePreprocessor.timeout=300",
str(path),
],
check=False,
)
status = "OK" if rc.returncode == 0 else f"FAIL ({rc.returncode})"
if rc.returncode != 0:
failures.append(name)
print(f" executed {path.name} -> {status}")
print()
if failures:
print(f"FAILURES: {failures}")
else:
print("ALL NOTEBOOKS OK")
if __name__ == "__main__":
main()