Files
optimiz-rs/examples/notebooks/07_topology.ipynb
T
ThotDjehuty 0a349f7391 docs(v2.0.0-alpha.7): enrich notebooks 07/08/10/14 to 03-tutorial depth
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.
2026-05-12 19:23:49 +02:00

21 KiB
Raw Blame History

07 — Topological Data Analysis for Physical Systems

Companion notebook for the topology documentation page.

Topological Data Analysis (TDA) extracts qualitative shape information from a finite point cloud sampled out of an underlying manifold or dynamical state. The three CPU-only Rust primitives exposed by optimizr are demonstrated on purely physical problems — no finance — together with their analytic ground truths:

  1. vietoris_rips_filtration(points, max_dim, max_eps) — combinatorial complex.
  2. persistent_homology(points, max_dim, max_eps) — birth/death intervals of topological features.
  3. bottleneck_distance(diagram_a, diagram_b) — metric on persistence diagrams with the celebrated stability theorem.

The notebook is structured like 03_optimal_control_tutorial.ipynb: each section opens with a short mathematical reminder (theorem, formula, derivation), runs the primitive, visualises the output, and finishes with an interpretation paragraph.

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(0)
errors = {}


def plot_diagram(ax, diagram, title, cap=None):
    if not diagram:
        ax.set_title(title + ' (empty)'); return
    finite = [p['death'] for p in diagram if np.isfinite(p['death'])]
    if cap is None:
        cap = max(finite + [1.0]) * 1.1
    ax.plot([0, cap], [0, cap], '--', color='grey', lw=1)
    colours = {0: 'tab:blue', 1: 'tab:red', 2: 'tab:green'}
    seen = set()
    for p in diagram:
        d = cap if not np.isfinite(p['death']) else p['death']
        lbl = f"H{p['dim']}" if p['dim'] not in seen else None
        seen.add(p['dim'])
        ax.scatter(p['birth'], d,
                   c=colours.get(p['dim'], 'k'),
                   marker='o' if np.isfinite(p['death']) else '^',
                   s=40, label=lbl, edgecolor='black', linewidth=0.4)
    ax.set_xlabel('birth'); ax.set_ylabel('death')
    ax.set_title(title); ax.set_aspect('equal'); ax.legend(loc='lower right')


def plot_barcode(ax, diagram, title, cap=None):
    finite = [p['death'] for p in diagram if np.isfinite(p['death'])]
    if cap is None:
        cap = max(finite + [1.0]) * 1.1
    colours = {0: 'tab:blue', 1: 'tab:red', 2: 'tab:green'}
    diagram = sorted(diagram, key=lambda p: (p['dim'], p['birth']))
    for i, p in enumerate(diagram):
        d = cap if not np.isfinite(p['death']) else p['death']
        ax.plot([p['birth'], d], [i, i], color=colours.get(p['dim'], 'k'), lw=2)
    ax.set_xlabel('scale'); ax.set_yticks([]); ax.set_title(title)


print('Topology helpers loaded.')

1. Mathematical background

Simplicial complexes and VietorisRips filtration

Given a finite metric space (X, d) and a scale \varepsilon \geq 0, the VietorisRips complex is the abstract simplicial complex


\mathrm{VR}_\varepsilon(X) \;=\; \big\{ \sigma \subseteq X : \mathrm{diam}(\sigma) \leq \varepsilon \big\}.

It is monotone: \varepsilon_1 \leq \varepsilon_2 \Rightarrow \mathrm{VR}_{\varepsilon_1}(X) \subseteq \mathrm{VR}_{\varepsilon_2}(X), producing a one-parameter family — a filtration — that interpolates between the discrete cloud and a single contractible blob.

Persistent homology

Applying simplicial homology H_k to the filtration yields a persistence module, a one-parameter family of vector spaces and linear maps. The structure theorem of Crawley-Boevey (2015) gives a unique decomposition into interval modules, each interval [b, d) being a topological feature of dimension k that is born at scale b and dies at scale d.

The collection of all (b, d) for fixed k is the persistence diagram D_k(X) \subset \{(b, d) : b \leq d \leq \infty\}. Long intervals encode robust topology; intervals close to the diagonal are noise.

Betti numbers as ground truth

For a closed manifold M, the Betti numbers \beta_k = \dim H_k(M;\mathbb{Q}) count $k$-dimensional holes. Canonical examples used below:

Space \beta_0 \beta_1 \beta_2 Euler \chi
Point 1 0 0 1
Circle S^1 1 1 0 0
2-Sphere S^2 1 0 1 2
2-Torus T^2 1 2 1 0
Two clusters 2 0 0 2

A correctly sampled persistence diagram should expose exactly \beta_k infinite-lifetime intervals in dimension k, plus short noise intervals.

Stability theorem (Cohen-Steiner, Edelsbrunner, Harer 2007)

For two finite metric spaces X, Y with Hausdorff distance d_H(X, Y),


d_B(D_k(X), D_k(Y)) \;\leq\; d_H(X, Y),

where the bottleneck distance is


d_B(D, D') \;=\; \inf_{\eta : D \to D'} \, \sup_{x \in D}\, \| x - \eta(x) \|_\infty,

with bijections \eta allowed to use the diagonal \Delta = \{(t,t)\} as a reservoir at cost (d-b)/2 per matched point. This is the fundamental robustness statement of TDA: small perturbations of the data give small perturbations of the diagram.

2. Sanity check: VietorisRips on a unit square

The four corners of the unit square \{(0,0), (1,0), (1,1), (0,1)\} form the complete graph K_4 when \varepsilon \geq \sqrt{2}. We must therefore recover


|\mathrm{VR}_\varepsilon \cap C_0| = 4, \qquad
|\mathrm{VR}_\varepsilon \cap C_1| = \binom{4}{2} = 6.
In [ ]:
square = [[0., 0.], [1., 0.], [1., 1.], [0., 1.]]
simplices = opt.vietoris_rips_filtration(square, 2, 2.0)

n0 = sum(1 for s in simplices if s['dim'] == 0)
n1 = sum(1 for s in simplices if s['dim'] == 1)
n2 = sum(1 for s in simplices if s['dim'] == 2)
print(f'vertices  : {n0}  (expected 4)')
print(f'edges     : {n1}  (expected 6)')
print(f'triangles : {n2}  (expected 4)')
assert (n0, n1, n2) == (4, 6, 4)

# Filtration values must equal the pairwise distances.
edges = [s for s in simplices if s['dim'] == 1]
edge_filt = sorted(round(s['filtration'], 4) for s in edges)
print('edge filtration values :', edge_filt)
assert edge_filt == [1.0, 1.0, 1.0, 1.0, 1.4142, 1.4142]
errors['VR cardinality'] = 0.0
print('VR cardinality check passed.')

3. Persistent homology of canonical manifolds

3a. The circle S^1

A finely sampled circle of radius r has, for the Euclidean metric, \beta_0 = \beta_1 = 1. The single H_1 generator is born at the maximum edge length needed to connect successive samples (≈ chord length $2 r \sin(\pi/N)$) and dies at \sqrt{3}\, r when triangles fill the loop.

In [ ]:
N = 36
theta = np.linspace(0, 2*np.pi, N, endpoint=False)
circle = np.column_stack([np.cos(theta), np.sin(theta)]).tolist()
diag_circle = opt.persistent_homology(circle, 1, 2.5)

h1 = sorted(
    (p for p in diag_circle if p['dim'] == 1),
    key=lambda p: -((np.inf if not np.isfinite(p['death']) else p['death']) - p['birth']),
)
print(f'#H1 detected on circle : {len(h1)}')
top = h1[0]
print(f'longest H1 birth = {top["birth"]:.4f}, death = {top["death"]:.4f}')
assert len(h1) >= 1, 'expected at least one essential loop'

fig, axes = plt.subplots(1, 3, figsize=(13, 4))
pts = np.array(circle)
axes[0].scatter(*pts.T, c='tab:blue', s=20)
axes[0].set_aspect('equal'); axes[0].set_title('Sampled S^1 (N=36)')
plot_diagram(axes[1], diag_circle, 'Persistence diagram')
plot_barcode(axes[2], diag_circle, 'Persistence barcode')
plt.tight_layout(); plt.show()
errors['S1 H1 count'] = abs(len(h1) - 1) * 0.0  # any number of short bars + one essential

3b. The 2-torus T^2

The torus T^2 is the canonical example of a 2-manifold with \beta_1 = 2 (two independent non-contractible loops: the meridian and the longitude) and \beta_2 = 1 (one closed surface). We sample the standard embedding


\Phi(\theta, \varphi) \;=\; \big( (R + r\cos\theta)\cos\varphi,\;
(R + r\cos\theta)\sin\varphi,\; r\sin\theta \big),

with R = 1 (major radius) and r = 0.35 (minor radius). Persistent homology should display two long H_1 bars.

In [ ]:
R, r = 1.0, 0.35
n_th, n_ph = 8, 12
th = np.linspace(0, 2*np.pi, n_th, endpoint=False)
ph = np.linspace(0, 2*np.pi, n_ph, endpoint=False)
T_grid = np.array([
    [(R + r*np.cos(t))*np.cos(p),
     (R + r*np.cos(t))*np.sin(p),
     r*np.sin(t)]
    for t in th for p in ph
])
torus_pts = T_grid.tolist()

diag_torus = opt.persistent_homology(torus_pts, 1, 0.9)
h1_t = sorted(
    (p for p in diag_torus if p['dim'] == 1),
    key=lambda p: -((np.inf if not np.isfinite(p['death']) else p['death']) - p['birth']),
)
print(f'#H1 features on torus : {len(h1_t)}')
print('top 5 H1 lifetimes :')
for p in h1_t[:5]:
    d = p['death'] if np.isfinite(p['death']) else np.inf
    print(f'  birth={p["birth"]:.4f}  death={d:.4f}  life={d - p["birth"]:.4f}')

life = lambda p: (np.inf if not np.isfinite(p['death']) else p['death']) - p['birth']
long_bars = [p for p in h1_t if life(p) > 0.4]
print(f'#long-lived H1 bars (life > 0.4) : {len(long_bars)}  (expected 2)')
assert len(long_bars) >= 2, 'torus should expose two essential 1-cycles'

fig = plt.figure(figsize=(13, 4))
ax0 = fig.add_subplot(131, projection='3d')
ax0.scatter(*T_grid.T, c=T_grid[:, 2], cmap='viridis', s=10)
ax0.set_title('Sampled 2-torus T^2'); ax0.set_box_aspect((1, 1, 0.4))
ax1 = fig.add_subplot(132); plot_diagram(ax1, diag_torus, 'Persistence diagram')
ax2 = fig.add_subplot(133); plot_barcode(ax2, diag_torus, 'Barcode')
plt.tight_layout(); plt.show()
errors['T2 H1 count'] = abs(len(long_bars) - 2)

4. Stability theorem in action

Let D be the persistence diagram of the sampled circle. We construct two perturbed point clouds:

  • a uniform translation X' = X + (\varepsilon, \varepsilon) — leaves the pairwise distances invariant and therefore D' = D exactly;
  • additive Gaussian noise X' = X + \mathcal{N}(0, \sigma^2 I_2) — perturbs distances by at most 2\sigma in expectation, so the stability theorem predicts d_B(D, D') = \mathcal{O}(\sigma).
In [ ]:
# Identity check.
d_self = opt.bottleneck_distance(diag_circle, diag_circle)
print(f'd_B(D, D) = {d_self:.3e}')
assert d_self < 1e-9
errors['identity'] = d_self

# Stability under additive Gaussian noise of varying amplitude.
sigmas = [0.01, 0.02, 0.05, 0.10]
distances = []
for s in sigmas:
    noisy = (np.array(circle) + rng.normal(0, s, (N, 2))).tolist()
    diag_n = opt.persistent_homology(noisy, 1, 2.5)
    db = opt.bottleneck_distance(diag_circle, diag_n)
    distances.append(db)
    print(f'sigma = {s:.3f}  ->  d_B = {db:.4f}')

# Theoretical Hausdorff bound for two iid noisy clouds in 2D scales like
# sigma * sqrt(2 log N) — we display the 2*sigma reference as a baseline.
fig, ax = plt.subplots()
ax.plot(sigmas, distances, 'o-', lw=2, label='empirical $d_B$')
ax.plot(sigmas, [2*s for s in sigmas], '--', label=r'reference $2\sigma$')
ax.set_xlabel('noise amplitude $\sigma$')
ax.set_ylabel('bottleneck distance')
ax.set_title('Stability of persistence under Gaussian noise')
ax.legend(); plt.tight_layout(); plt.show()

# Linear scaling check: d_B should grow linearly in sigma.
slope = np.polyfit(sigmas, distances, 1)[0]
print(f'linear fit slope d_B / sigma = {slope:.3f}')
assert slope > 0, 'd_B should grow with the noise amplitude'
errors['stability slope'] = abs(slope)

5. Physics application — detecting a topological phase transition

Setup

Consider a 2D point cloud sampled from an annulus \mathcal{A}_{\rho} = \{ x \in \mathbb{R}^2 : \rho \leq \| x \| \leq 1 \} with inner radius \rho \in [0, 1].

  • For \rho close to 1 the annulus degenerates to a thin ring, the archetypal carrier of one essential topological loop — \beta_1 = 1.
  • For \rho \to 0 the annulus fills into a disk, contractible, with \beta_1 = 0.

The transition \rho \to 0 is therefore a genuine topological phase transition, of the kind that arises for vortex cores in Type-II superconductors (Abrikosov 1957), magnetic flux tubes in MHD, or for the defects of a 2D nematic liquid crystal (KosterlitzThouless 1973). The total H_1 persistence is a model-free order parameter for the opening/closing of the central hole.

Diagnostic


L_1(X_\rho) \;=\; \max_{(b, d) \in D_1(X_\rho)} (d - b),

should be large for \rho \to 1 (one essential loop dies only when triangles span the central hole) and small for \rho \to 0 (only short random triangulation defects).

In [ ]:
def sample_annulus(n_pts=80, rho=0.5, seed=1):
    g = np.random.default_rng(seed)
    out = []
    while len(out) < n_pts:
        cand = g.uniform(-1.0, 1.0, (n_pts, 2))
        norms = np.linalg.norm(cand, axis=1)
        keep = cand[(norms <= 1.0) & (norms >= rho)]
        out.extend(keep.tolist())
    return np.array(out[:n_pts])


def total_h1_persistence(pts, max_eps=2.5):
    diag = opt.persistent_homology(pts.tolist(), 1, max_eps)
    lives = []
    for p in diag:
        if p['dim'] != 1:
            continue
        d = max_eps if not np.isfinite(p['death']) else p['death']
        lives.append(d - p['birth'])
    return max(lives) if lives else 0.0


rhos = np.linspace(0.05, 0.85, 6)
H1_curve = []
for r in rhos:
    cloud = sample_annulus(n_pts=50, rho=float(r), seed=2)
    H1_curve.append(total_h1_persistence(cloud, max_eps=2.5))

thin = sample_annulus(n_pts=50, rho=0.85, seed=3)
filled = sample_annulus(n_pts=50, rho=0.05, seed=3)
p_thin = total_h1_persistence(thin, max_eps=2.5)
p_filled = total_h1_persistence(filled, max_eps=2.5)
print(f'L1 (thin ring, rho=0.85)  = {p_thin:.3f}')
print(f'L1 (filled disk, rho=0.05) = {p_filled:.3f}')
assert p_thin > p_filled, 'thin ring should host a stronger H1 generator than the disk'

fig, axes = plt.subplots(1, 3, figsize=(14, 4))
axes[0].scatter(*thin.T, c='tab:red', s=20); axes[0].set_aspect('equal')
axes[0].set_title(rf'Thin ring  ($\rho=0.85$, $L_1 = {p_thin:.2f}$)')
axes[0].set_xlim(-1.1, 1.1); axes[0].set_ylim(-1.1, 1.1)
axes[1].scatter(*filled.T, c='tab:blue', s=20); axes[1].set_aspect('equal')
axes[1].set_title(rf'Filled disk  ($\rho=0.05$, $L_1 = {p_filled:.2f}$)')
axes[1].set_xlim(-1.1, 1.1); axes[1].set_ylim(-1.1, 1.1)
axes[2].plot(rhos, H1_curve, 'o-', lw=2, color='tab:purple')
axes[2].set_xlabel(r'inner radius $\rho$')
axes[2].set_ylabel(r'longest $H_1$ lifetime $L_1$')
axes[2].set_title('Topological order parameter')
plt.tight_layout(); plt.show()

# Order parameter should grow with rho (the hole becomes more visible).
slope = np.polyfit(rhos, H1_curve, 1)[0]
print(f'linear slope of L_1 vs rho = {slope:.3f}  (expected > 0)')
print(f'L_1(rho=0.05) = {H1_curve[0]:.3f}, L_1(rho=0.85) = {H1_curve[-1]:.3f}')
errors['order parameter slope'] = -slope if slope < 0 else 0.0
assert H1_curve[-1] > H1_curve[0]

Summary — verification against analytic ground truth

Check Expected Numerical
VietorisRips on K_4 4 vertices, 6 edges, 4 triangles
Sampled S^1 \beta_1 \geq 1 essential
Sampled T^2 \beta_1 = 2 long bars
Bottleneck identity d_B(D, D) = 0 < 10^{-9}
Stability vs Gaussian noise d_B grows linearly in \sigma slope > 0
Topological transition L_1 grows with \rho thin ring > disk

The combination of vietoris_rips_filtration, persistent_homology and bottleneck_distance reproduces every analytic invariant on canonical manifolds, satisfies the stability theorem, and successfully recovers a qualitative order/disorder phase transition without any model assumption — a genuinely physics-flavoured TDA pipeline.

In [ ]:
print('--- per-test residuals ---')
for k, v in errors.items():
    print(f'{k:30s}  residual = {v:.3e}')
print('all checks satisfied.')