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.
Reproducibility: environment setup
This post runs in its own virtualenv, registered as a Jupyter kernel per this blog’s convention:
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 itertoolsimport matplotlib.pyplot as pltimport networkx as nximport numpy as npimport ripserimport scipyfrom IPython.display import Markdownfrom matplotlib.colors import LinearSegmentedColormapfrom scipy.spatial.distance import pdist, squareformprint(f"numpy {np.__version__} · scipy {scipy.__version__} · "f"networkx {nx.__version__} · ripser {ripser.__version__}")# --- shared color roles (validated palette) ---------------------------------INK ="#0b0b0b"# vertices, primary textMUTED ="#898781"# axis labels, ticksGRIDC ="#e1e0d9"# hairline gridBLUE ="#2a78d6"# edges (1-simplices), H0 seriesORANGE ="#eb6834"# H1 seriesVIOLET ="#4a3aa7"# filled triangles (2-simplices), H2 seriesSEQ_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)ifnot 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 =0if show_triangles:for i, j, k in itertools.combinations(range(n), 3):ifmax(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 =0for 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) inzip(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
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.
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\):
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 inzip(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) inzip("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.
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\).
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)
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).
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\).
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):returnint(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).