What TDA Actually Builds: A Filtered Simplicial Complex, Not a Fancy Graph

Machine Learning
Topology
TDA
Mathematics
Author

Ravi Kalia

Published

July 23, 2026

What TDA Actually Builds: A Filtered Simplicial Complex, Not a Fancy Graph

TDA builds a filtered simplicial complex over a fixed vertex set — not a threshold graph alone. Graphs cannot distinguish a hollow cycle from a filled triangle; simplicial complexes can.

This post runs in its own virtualenv, registered as a Jupyter kernel per this blog’s convention:

python3 -m venv .venv-tda-filtered
.venv-tda-filtered/bin/pip install numpy scipy matplotlib networkx ripser persim ipykernel
.venv-tda-filtered/bin/python -m ipykernel install --user --name tda-filtered-blog

Quarto discovers Jupyter kernels through the Python it runs, so if it reports Jupyter kernel 'tda-filtered-blog' not found, point it at the post’s venv:

QUARTO_PYTHON=.venv-tda-filtered/bin/python quarto render posts/tda-filtered-simplicial-complexes/index.qmd

Key versions at render time are printed in the setup cell below. The random seed is fixed, so every figure and number reproduces exactly.

Setup: imports, style constants, and the drawing helper used throughout
import itertools

import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
import ripser
import scipy
from IPython.display import Markdown
from matplotlib.colors import LinearSegmentedColormap
from scipy.spatial.distance import pdist, squareform

print(
    f"numpy {np.__version__} · scipy {scipy.__version__} · "
    f"networkx {nx.__version__} · ripser {ripser.__version__}"
)

# --- shared color roles (validated palette) ---------------------------------
INK = "#0b0b0b"      # vertices, primary text
MUTED = "#898781"    # axis labels, ticks
GRIDC = "#e1e0d9"    # hairline grid
BLUE = "#2a78d6"     # edges (1-simplices), H0 series
ORANGE = "#eb6834"   # H1 series
VIOLET = "#4a3aa7"   # filled triangles (2-simplices), H2 series
SEQ_RAMP = LinearSegmentedColormap.from_list(
    "blue_seq", ["#cde2fb", "#86b6ef", "#3987e5", "#1c5cab", "#0d366b"]
)

plt.rcParams.update(
    {
        "font.family": "sans-serif",
        "axes.edgecolor": MUTED,
        "axes.labelcolor": MUTED,
        "xtick.color": MUTED,
        "ytick.color": MUTED,
        "axes.titlecolor": INK,
        "figure.dpi": 120,
    }
)


def style_axes(ax, keep_ticks=False):
    """Recessive chrome: no spines, optional ticks."""
    for side in ("top", "right", "left", "bottom"):
        ax.spines[side].set_visible(False)
    if not keep_ticks:
        ax.set_xticks([])
        ax.set_yticks([])


def draw_complex(ax, eps, show_triangles=True, node_size=210, font_size=8.5):
    """Draw the Vietoris-Rips complex at scale eps over the fixed points.

    Filled violet patches are 2-simplices (3-cliques of the threshold
    graph), blue segments are 1-simplices, ink dots are the fixed vertices.
    """
    n = len(points)
    dm = squareform(pdist(points))
    n_tri = 0
    if show_triangles:
        for i, j, k in itertools.combinations(range(n), 3):
            if max(dm[i, j], dm[i, k], dm[j, k]) <= eps:
                n_tri += 1
                ax.add_patch(
                    plt.Polygon(
                        points[[i, j, k]],
                        closed=True,
                        facecolor=VIOLET,
                        alpha=0.20,
                        edgecolor="none",
                        zorder=1,
                    )
                )
    n_edges = 0
    for i, j in itertools.combinations(range(n), 2):
        if dm[i, j] <= eps:
            n_edges += 1
            ax.plot(
                points[[i, j], 0], points[[i, j], 1], color=BLUE, lw=2, zorder=2
            )
    ax.scatter(points[:, 0], points[:, 1], s=node_size, color=INK, zorder=3)
    for lbl, (x, y) in zip(labels, points):
        ax.text(
            x, y, lbl, color="white", fontsize=font_size, fontweight="bold",
            ha="center", va="center", zorder=4,
        )
    ax.set_aspect("equal")
    ax.set_xlim(-1.45, 3.35)
    ax.set_ylim(-1.45, 1.95)
    style_axes(ax)
    return n_edges, n_tri
numpy 2.5.1 · scipy 1.18.0 · networkx 3.6.1 · ripser 0.6.15

1 Toy point cloud

Data provenance (synthetic, hand-built):

  • Six points: unit circle at equal angles + Gaussian noise (\(\sigma = 0.08\)).
  • Two points at \((2.6, 1.10)\) and \((2.9, 1.45)\): separate pair far from the ring.
  • Fixed seed (rng = 42); all later figures use these coordinates.
  • Stands in for cyclic dynamics plus an outlying regime (e.g. periodic signal + burst).
  • Expected phenomena: disconnected pieces at small \(\varepsilon\), a loop at intermediate \(\varepsilon\), triangles that eventually fill the loop.
Code
rng = np.random.default_rng(42)

angles = np.linspace(0, 2 * np.pi, 6, endpoint=False)
circle_points = np.column_stack([np.cos(angles), np.sin(angles)])
circle_points += rng.normal(scale=0.08, size=circle_points.shape)

pair = np.array([[2.6, 1.10], [2.9, 1.45]])

points = np.vstack([circle_points, pair])
labels = list("ABCDEFGH")

fig, ax = plt.subplots(figsize=(7, 4))
draw_complex(ax, eps=0.0, show_triangles=False)
ax.set_title("The toy point cloud", fontsize=12, fontweight="bold")
plt.show()
Figure 1: The running example: eight 2D points. A–F sit roughly on a circle; G and H form a tight pair off to the upper right. Nothing topological has happened yet — this is just a set of coordinates.

Vertices A–F: would-be loop. G–H: satellite pair. Coordinates alone carry no topology.

2 Distance matrix

TDA consumes the pairwise distance matrix \(D\) with \(D_{ij} = \|x_i - x_j\|\).

  • Input only; not a topological summary.
  • Example: \(D_{GH} \approx 0.46\) (closest pair).
  • Powers of \(D\) do not count graph walks; shape requires an explicit scale parameter.
Code
D = squareform(pdist(points))

fig, ax = plt.subplots(figsize=(6.4, 5.4))
im = ax.imshow(D, cmap=SEQ_RAMP)
ax.set_xticks(range(8), labels)
ax.set_yticks(range(8), labels)
ax.tick_params(length=0)
for side in ("top", "right", "left", "bottom"):
    ax.spines[side].set_visible(False)
mid = D.max() / 2
for i in range(8):
    for j in range(8):
        ax.text(
            j, i, f"{D[i, j]:.2f}",
            ha="center", va="center", fontsize=7.5,
            color="white" if D[i, j] > mid else INK,
        )
cbar = fig.colorbar(im, ax=ax, shrink=0.85)
cbar.set_label("Euclidean distance", color=MUTED)
cbar.outline.set_visible(False)
ax.set_title("Pairwise distance matrix", fontsize=12, fontweight="bold")
plt.show()
Figure 2: The 8×8 Euclidean distance matrix, annotated to two decimals. Darker blue = farther apart. The matrix is symmetric with a zero diagonal. It records pairwise distances only — which pairs are close — and says nothing by itself about components, loops, or voids.

3 Threshold graphs

At scale \(\varepsilon \geq 0\), graph \(G_\varepsilon\):

  • Vertices: fixed set A–H at every scale.
  • Edges: \((i,j)\) whenever \(D_{ij} \leq \varepsilon\).

Nested family:

\[ G_{\varepsilon_1} \subseteq G_{\varepsilon_2} \subseteq G_{\varepsilon_3} \subseteq \cdots \qquad \text{whenever } \varepsilon_1 \leq \varepsilon_2 \leq \varepsilon_3 \leq \cdots \]

Edges only accumulate; vertices never change.

Code
threshold_eps = [0.6, 0.9, 1.25, 1.9]

fig, axes = plt.subplots(1, 4, figsize=(13, 3.4))
for ax, eps in zip(axes, threshold_eps):
    n_edges, _ = draw_complex(ax, eps, show_triangles=False, node_size=130, font_size=7)
    G = nx.Graph()
    G.add_nodes_from(labels)
    G.add_edges_from(
        (labels[i], labels[j])
        for i, j in itertools.combinations(range(8), 2)
        if D[i, j] <= eps
    )
    n_comp = nx.number_connected_components(G)
    edge_word = "edge" if n_edges == 1 else "edges"
    ax.set_title(
        f"$\\varepsilon$ = {eps}\n{n_edges} {edge_word} · {n_comp} components",
        fontsize=10,
    )
fig.suptitle(
    "One fixed vertex set, a growing edge set", fontsize=12, fontweight="bold", y=1.06
)
plt.show()
Figure 3: Threshold graphs at four scales, drawn over the identical fixed coordinates. ε = 0.6: only the closest pair (G–H, distance 0.46) is joined — seven components. ε = 0.9: A–F and C–D have joined in — five components. ε = 1.25: the six circle points close into a ring, leaving two components. ε = 1.9: many long edges have appeared; the pair still hangs on as its own component until ε ≈ 1.97. Every panel’s edge set contains the previous panel’s.

Component counts at this stage match single-linkage clustering. Loops and voids require higher-dimensional simplices.

4 Filled vs hollow cycles

At \(\varepsilon = 1.75\), points C, D, E are pairwise within range. Two distinct objects:

  • Boundary: three edges C–D–E–C → 1-cycle that may enclose a hole.
  • Filled 2-simplex \(\{C,D,E\}\) → certifies the cycle bounds a patch; hole killed.
Code
sub = [labels.index(c) for c in "CDE"]
tri = points[sub]

fig, axes = plt.subplots(1, 2, figsize=(9, 3.8))
for ax, filled in zip(axes, [False, True]):
    if filled:
        ax.add_patch(
            plt.Polygon(tri, closed=True, facecolor=VIOLET, alpha=0.25,
                        edgecolor="none", zorder=1)
        )
    for i, j in itertools.combinations(range(3), 2):
        ax.plot(tri[[i, j], 0], tri[[i, j], 1], color=BLUE, lw=2.2, zorder=2)
    ax.scatter(tri[:, 0], tri[:, 1], s=260, color=INK, zorder=3)
    for lbl, (x, y) in zip("CDE", tri):
        ax.text(x, y, lbl, color="white", fontsize=10, fontweight="bold",
                ha="center", va="center", zorder=4)
    ax.set_aspect("equal")
    ax.set_xlim(-1.35, 0.05)
    ax.set_ylim(-1.25, 1.15)
    style_axes(ax)
axes[0].set_title("Boundary only: three 1-simplices\n(a cycle with a hole)", fontsize=10)
axes[1].set_title("Filled 2-simplex {C, D, E}\n(one 3-way object; hole destroyed)", fontsize=10)
plt.show()
Figure 4: Left: the triangle boundary — three edges C–D, D–E, C–E, each a relationship between exactly two points. This is a 1-dimensional cycle: you can walk around it, and it encloses a hole. Right: the filled 2-simplex {C, D, E} — a single three-way relationship, drawn as a violet membrane. The filled triangle is a different object, not a styling of the edges: its presence declares that the cycle around it bounds nothing, killing the hole.

An edge is a two-way relation. A 2-simplex is one three-way relation. Graph decorations cannot express fill status.

5 Simplicial complexes

Simplex: finite vertex set; dimension = \(|S| - 1\).

  • \(\{v\}\): 0-simplex (vertex).
  • \(\{u,v\}\): 1-simplex (edge).
  • \(\{u,v,w\}\): 2-simplex (filled triangle).
  • \((k{+}1)\) vertices: \(k\)-simplex.

Abstract simplicial complex: collection of simplices closed under faces — every face of an included simplex is included.

Closure example: if \(\{C,D,E\}\) is present, then \(\{C,D\}, \{D,E\}, \{C,E\}, \{C\}, \{D\}, \{E\}\) must be present. A generic hypergraph need not satisfy this.

Left panel above: complex without \(\{C,D,E\}\). Right panel: same skeleton plus \(\{C,D,E\}\).

6 Vietoris-Rips construction

Vietoris–Rips complex \(\mathrm{VR}_\varepsilon\): one simplex per clique of the threshold graph — equivalently, every subset whose pairwise distances are \(\leq \varepsilon\).

\[ \{x_{i_0}, \ldots, x_{i_k}\} \in \mathrm{VR}_\varepsilon \iff D_{i_p i_q} \leq \varepsilon \ \text{ for every pair } p, q . \]

Face closure is automatic (every subset of a clique is a clique).

Code
rips_eps = [0.9, 1.25, 1.75, 1.9]

fig, axes = plt.subplots(2, 2, figsize=(11, 7))
for ax, eps in zip(axes.flat, rips_eps):
    n_edges, n_tri = draw_complex(ax, eps, node_size=150, font_size=7.5)
    ax.set_title(
        f"$\\varepsilon$ = {eps} · {n_edges} edges · {n_tri} filled triangles",
        fontsize=10,
    )
fig.suptitle(
    "Vietoris–Rips complexes: every clique becomes a simplex",
    fontsize=12, fontweight="bold",
)
fig.tight_layout()
plt.show()
Figure 5: Vietoris–Rips complexes at four scales. Ink dots are 0-simplices (always all eight), blue segments 1-simplices, violet patches filled 2-simplices; where patches overlap the violet deepens. ε = 0.9: a few edges, no triangles. ε = 1.25: the ring closes — a genuine 1-dimensional loop, with no triangles yet to dispute it. ε = 1.75: four triangles have filled in and the loop has shrunk to the still-unfilled middle. ε = 1.9: enough triangles overlap that the central cycle is entirely filled — the loop is dead.

Simplex counts by dimension (including 3-simplices not drawable in 2D):

Count simplices by dimension at each scale (cliques of the threshold graph)
def simplex_counts(eps, max_size=4):
    counts = {}
    for size in range(1, max_size + 1):
        counts[size - 1] = sum(
            1
            for combo in itertools.combinations(range(8), size)
            if all(D[i, j] <= eps for i, j in itertools.combinations(combo, 2))
        )
    return counts

rows = ["| $\\varepsilon$ | 0-simplices | 1-simplices | 2-simplices | 3-simplices |",
        "|---|---|---|---|---|"]
for eps in [0.9, 1.25, 1.75, 1.9, 2.0]:
    c = simplex_counts(eps)
    rows.append(f"| {eps} | {c[0]} | {c[1]} | {c[2]} | {c[3]} |")
Markdown("\n".join(rows))
\(\varepsilon\) 0-simplices 1-simplices 2-simplices 3-simplices
0.9 8 3 0 0
1.25 8 7 0 0
1.75 8 11 4 0
1.9 8 13 8 0
2.0 8 15 12 4

At \(\varepsilon = 2.0\), four 3-simplices appear when edge C–F (\(\approx 1.99\)) completes four tetrahedra. The Rips complex is abstract — built from \(D\) only, with no embedding dimension.

Čech complex (alternative): simplex when \(\varepsilon/2\)-balls share a common point; stricter, better guarantees, higher cost. This post uses Rips (standard in fast libraries).

7 Filtration and persistence

Nested Rips complexes:

\[ \mathrm{VR}_{\varepsilon_1} \subseteq \mathrm{VR}_{\varepsilon_2} \subseteq \cdots \]

Each simplex enters at its diameter (longest pairwise edge length).

Distinction:

  • Filtration: which simplices exist at each \(\varepsilon\) (combinatorial bookkeeping).
  • Persistent homology: which components, loops, voids exist at each scale, and their birth/death scales (requires homology across the filtration).

In dimension \(k\): \(k\)-cycles minus boundaries of \((k{+}1)\)-simplices → Betti number \(\beta_k\).

\[ \beta_k = \dim\ker\partial_k - \operatorname{rank}\partial_{k+1}. \]

Persistent homology tracks each feature from birth scale to death scale across the full filtration.

8 Ripser output

ripser computes Rips persistence from the point cloud. Conventions:

  • Simplex enters when diameter \(\leq \varepsilon\).
  • Filtration values match Euclidean distances in the figures.
  • One \(H_0\) component has death \(= \infty\) once everything connects.
Compute persistent homology with ripser (dimensions 0, 1, 2)
res = ripser.ripser(points, maxdim=2)
dgms = res["dgms"]
for k, dgm in enumerate(dgms):
    print(f"H{k} intervals (birth, death):")
    for b, d in dgm:
        print(f"  ({b:.3f}, {'∞' if np.isinf(d) else f'{d:.3f}'})")
H0 intervals (birth, death):
  (0.000, 0.461)
  (0.000, 0.852)
  (0.000, 0.855)
  (0.000, 1.032)
  (0.000, 1.080)
  (0.000, 1.125)
  (0.000, 1.970)
  (0.000, ∞)
H1 intervals (birth, death):
  (1.229, 1.827)
H2 intervals (birth, death):
  (1.881, 1.989)

8.1 Persistence diagram

Each feature → point (birth \(\varepsilon\), death \(\varepsilon\)). Distance above the diagonal = lifetime.

Code
finite_max = max(d for dgm in dgms for _, d in dgm if np.isfinite(d))
lim = finite_max * 1.12
inf_y = lim * 1.06

fig, ax = plt.subplots(figsize=(6.2, 6.2))
ax.set_facecolor("none")
ax.plot([0, lim], [0, lim], color=GRIDC, lw=1.2, zorder=1)
ax.axhline(inf_y, color=MUTED, lw=1, ls=(0, (4, 4)), zorder=1)
ax.text(0.12, inf_y, "∞ ", ha="right", va="center", color=MUTED, fontsize=11)

series = [("$H_0$ components", BLUE, "o"),
          ("$H_1$ loops", ORANGE, "s"),
          ("$H_2$ voids", VIOLET, "^")]
for dgm, (name, color, marker) in zip(dgms, series):
    births = dgm[:, 0]
    deaths = np.where(np.isinf(dgm[:, 1]), inf_y, dgm[:, 1])
    ax.scatter(births, deaths, s=70, color=color, marker=marker, label=name,
               zorder=3, edgecolors="white", linewidths=1.2)

ax.annotate("the ring A–F\n(1.23, 1.83)", xy=(1.23, 1.83), xytext=(0.35, 1.62),
            fontsize=9, color=INK,
            arrowprops=dict(arrowstyle="-", color=MUTED, lw=1))
ax.annotate("G–H pair joins\nthe rest (1.97)", xy=(0.0, 1.97), xytext=(0.42, 2.06),
            fontsize=9, color=INK,
            arrowprops=dict(arrowstyle="-", color=MUTED, lw=1))
ax.set_xlim(-0.08, lim)
ax.set_ylim(-0.08, inf_y * 1.05)
ax.set_xlabel("birth scale $\\varepsilon$")
ax.set_ylabel("death scale $\\varepsilon$")
ax.set_aspect("equal")
for side in ("top", "right"):
    ax.spines[side].set_visible(False)
ax.grid(color=GRIDC, lw=0.6)
ax.set_axisbelow(True)
ax.legend(frameon=False, loc="lower right", labelcolor=INK)
ax.set_title("Persistence diagram", fontsize=12, fontweight="bold")
plt.show()
Figure 6: Persistence diagram for the toy cloud. Both axes are the scale parameter ε — there is no time axis. Blue circles (H₀): component mergers, including the long-lived bar for the G–H pair dying at ε ≈ 1.97, and one immortal component plotted on the dashed ∞ line. The orange square (H₁) is the ring A–F: born 1.23, dead 1.83 — far off the diagonal, hence a real feature. The violet triangle (H₂) is a fleeting abstract void, born 1.88 and filled at 1.99 by the tetrahedron {C,D,E,F} — close to the diagonal, hence more curiosity than signal.

8.2 Persistence barcode

Same intervals as horizontal bars over \(\varepsilon\).

Code
fig, ax = plt.subplots(figsize=(8.5, 4.6))
y = 0
yticks, ylabels = [], []
group_gap = 1.6
for dgm, (name, color, _) in zip(dgms, series):
    order = np.argsort(dgm[:, 0] + np.where(np.isinf(dgm[:, 1]), 1e9, dgm[:, 1]))
    group_top = y
    for b, d in dgm[order][::-1]:
        if np.isinf(d):
            ax.plot([b, lim * 1.02], [y, y], color=color, lw=3.5,
                    solid_capstyle="round")
            ax.annotate("", xy=(lim * 1.08, y), xytext=(lim * 1.02, y),
                        arrowprops=dict(arrowstyle="->", color=color, lw=1.6))
        else:
            ax.plot([b, d], [y, y], color=color, lw=3.5, solid_capstyle="round")
        y += 1
    yticks.append((group_top + y - 1) / 2)
    ylabels.append(name)
    y += group_gap
ax.set_yticks(yticks, ylabels, fontsize=11, color=INK)
ax.tick_params(axis="y", length=0)
ax.set_xlim(-0.05, lim * 1.12)
ax.set_xlabel("scale $\\varepsilon$")
for side in ("top", "right", "left"):
    ax.spines[side].set_visible(False)
ax.grid(axis="x", color=GRIDC, lw=0.6)
ax.set_axisbelow(True)
ax.set_title("Persistence barcode", fontsize=12, fontweight="bold")
plt.show()
Figure 7: Persistence barcode — the same intervals as the diagram, drawn as bars over the ε-axis. Reading H₀ top-down: the shortest bar ends at 0.46 (G and H merge), the next at 0.85 twice (A–F and C–D join the circle group), then 1.03, 1.08, 1.12 as the circle assembles, one long bar to 1.97 (the pair holding out), and one bar running off to infinity (the final single component). The lone H₁ bar spans [1.23, 1.83] — the ring. The sliver H₂ bar spans [1.88, 1.99].

8.3 Feature reading

  • \(H_1\) ring: interval \([1.23, 1.83]\); persistence \(0.60\); loop born when B–C closes A–F, dies when interior fills.
  • \(H_0\) merges: seven finite bars; death scales match edge lengths (0.46 G–H, 0.85, 0.85, 1.03, 1.08, 1.12, 1.97 A–G).
  • \(H_2\) void: \([1.88, 1.99]\); abstract enclosed void filled by tetrahedra at C–F arrival; short lifetime ≈ noise.

8.4 Betti numbers at fixed scales

\(\beta_k(\varepsilon)\) = count of bars with \(b \leq \varepsilon < d\).

Betti numbers at selected scales, from the intervals, cross-checked against networkx
def betti(eps, dgm):
    return int(np.sum((dgm[:, 0] <= eps) & (eps < dgm[:, 1])))

rows = ["| $\\varepsilon$ | $\\beta_0$ (components) | $\\beta_1$ (loops) | $\\beta_2$ (voids) | graph components (networkx) |",
        "|---|---|---|---|---|"]
for eps in [0.6, 0.9, 1.25, 1.75, 1.9, 2.0]:
    G = nx.Graph()
    G.add_nodes_from(range(8))
    G.add_edges_from(
        (i, j) for i, j in itertools.combinations(range(8), 2) if D[i, j] <= eps
    )
    nx_comp = nx.number_connected_components(G)
    b0, b1, b2 = (betti(eps, dgm) for dgm in dgms)
    assert b0 == nx_comp, "β₀ must equal the number of graph components"
    rows.append(f"| {eps} | {b0} | {b1} | {b2} | {nx_comp} |")
Markdown("\n".join(rows))
\(\varepsilon\) \(\beta_0\) (components) \(\beta_1\) (loops) \(\beta_2\) (voids) graph components (networkx)
0.6 7 0 0 7
0.9 5 0 0 5
1.25 2 1 0 2
1.75 2 1 0 2
1.9 2 0 1 2
2.0 1 0 0 1

At \(\varepsilon = 1.25\): \((\beta_0, \beta_1, \beta_2) = (2, 1, 0)\). Betti counts at two scales do not identify whether the same loop persists; the single \(H_1\) bar \([1.23, 1.83]\) does.

9 Glossary

Terms tied to the eight-point example:

  • Point cloud: finite coordinates or distance matrix (A–H).
  • Distance matrix: \(D_{GH} \approx 0.46\); topology-free input.
  • Simplex: \(\{G\}\), \(\{G,H\}\), \(\{C,D,E\}\), \(\{C,D,E,F\}\), etc.
  • Simplicial complex: simplices closed under faces.
  • Closure property: triangle implies its edges and vertices.
  • Vietoris–Rips complex: cliques of threshold graph = simplices.
  • Filtration: \(\mathrm{VR}_{\varepsilon_1} \subseteq \mathrm{VR}_{\varepsilon_2} \subseteq \cdots\).
  • Betti numbers: \(\beta_0\) components, \(\beta_1\) loops, \(\beta_2\) voids.
  • Persistence diagram: (birth, death) per feature; ring at \((1.23, 1.83)\).
  • Persistence barcode: same intervals as bars over \(\varepsilon\).
  • Persistence landscape: diagram → real-valued functions for ML pipelines.
  • Bottleneck distance: max single-point matching distance between diagrams.
  • Wasserstein distance: sum of matching distances between diagrams.

10 Common misconceptions

  • “TDA is a weighted multigraph.” Edges have two endpoints; cannot encode filled \(\{C,D,E\}\) or fill status of cycles.
  • “Powers of \(D\) reveal topology.” Only a filtration + homology over nested complexes does.
  • “Persistence diagram has a time axis.” Both axes are scale \(\varepsilon\).
  • “Long graph cycle ⇒ persistent loop.” \(\beta_1 = 0\) at \(\varepsilon = 1.9\) despite many graph cycles — higher simplices fill them.

11 References