Spaces:
Sleeping
Sleeping
| """ | |
| TDA Explorer - Interactive Topological Data Analysis | |
| by Dr. Milan Amrutkumar Joshi | |
| """ | |
| import gradio as gr | |
| import numpy as np | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| from matplotlib.collections import LineCollection | |
| from matplotlib.patches import Circle as MplCircle | |
| from scipy.spatial.distance import pdist, squareform | |
| from ripser import ripser | |
| import traceback | |
| import warnings | |
| warnings.filterwarnings("ignore") | |
| # โโ Color Palette โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| PALETTE = { | |
| "point": "#E63946", | |
| "edge": "#457B9D", | |
| "circle": "#A8DADC", | |
| "h0": "#E63946", | |
| "h1": "#457B9D", | |
| "h2": "#2A9D8F", | |
| "bg": "#F1FAEE", | |
| "dark": "#1D3557", | |
| } | |
| # โโ Dataset Generators โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def make_circle(n=150, noise=0.05): | |
| t = np.linspace(0, 2 * np.pi, n, endpoint=False) | |
| pts = np.column_stack([np.cos(t), np.sin(t)]) | |
| return pts + np.random.normal(0, noise, pts.shape) | |
| def make_figure_eight(n=150, noise=0.05): | |
| half = n // 2 | |
| t1 = np.linspace(0, 2 * np.pi, half, endpoint=False) | |
| t2 = np.linspace(0, 2 * np.pi, n - half, endpoint=False) | |
| left = np.column_stack([np.cos(t1) - 1, np.sin(t1)]) | |
| right = np.column_stack([np.cos(t2) + 1, np.sin(t2)]) | |
| pts = np.vstack([left, right]) | |
| return pts + np.random.normal(0, noise, pts.shape) | |
| def make_two_circles(n=150, noise=0.05): | |
| half = n // 2 | |
| t1 = np.linspace(0, 2 * np.pi, half, endpoint=False) | |
| t2 = np.linspace(0, 2 * np.pi, n - half, endpoint=False) | |
| outer = np.column_stack([np.cos(t1), np.sin(t1)]) | |
| inner = np.column_stack([0.4 * np.cos(t2), 0.4 * np.sin(t2)]) | |
| pts = np.vstack([outer, inner]) | |
| return pts + np.random.normal(0, noise, pts.shape) | |
| def make_clusters(n=150, noise=0.05): | |
| k = 3 | |
| per = n // k | |
| centers = np.array([[0, 0], [2.5, 0], [1.25, 2.2]]) | |
| pts = [] | |
| for i in range(k): | |
| count = per if i < k - 1 else n - per * (k - 1) | |
| pts.append(centers[i] + np.random.normal(0, 0.15 + noise, (count, 2))) | |
| return np.vstack(pts) | |
| def make_moons(n=150, noise=0.08): | |
| half = n // 2 | |
| t1 = np.linspace(0, np.pi, half) | |
| t2 = np.linspace(0, np.pi, n - half) | |
| upper = np.column_stack([np.cos(t1), np.sin(t1)]) | |
| lower = np.column_stack([1 - np.cos(t2), 1 - np.sin(t2) - 0.5]) | |
| pts = np.vstack([upper, lower]) | |
| return pts + np.random.normal(0, noise, pts.shape) | |
| def make_sphere(n=200, noise=0.05): | |
| phi = np.random.uniform(0, 2 * np.pi, n) | |
| cos_theta = np.random.uniform(-1, 1, n) | |
| theta = np.arccos(cos_theta) | |
| x = np.sin(theta) * np.cos(phi) | |
| y = np.sin(theta) * np.sin(phi) | |
| z = np.cos(theta) | |
| pts = np.column_stack([x, y, z]) | |
| return pts + np.random.normal(0, noise, pts.shape) | |
| def make_torus(n=300, noise=0.05): | |
| R, r = 2.0, 0.7 | |
| theta = np.random.uniform(0, 2 * np.pi, n) | |
| phi = np.random.uniform(0, 2 * np.pi, n) | |
| x = (R + r * np.cos(theta)) * np.cos(phi) | |
| y = (R + r * np.cos(theta)) * np.sin(phi) | |
| z = r * np.sin(theta) | |
| pts = np.column_stack([x, y, z]) | |
| return pts + np.random.normal(0, noise, pts.shape) | |
| def make_random(n=150, noise=0.05): | |
| return np.random.uniform(-2, 2, (n, 2)) | |
| DATASETS = { | |
| "Circle (H1=1)": make_circle, | |
| "Figure-8 (H1=2)": make_figure_eight, | |
| "Concentric Circles": make_two_circles, | |
| "3 Clusters (H0=3)": make_clusters, | |
| "Moons": make_moons, | |
| "Sphere 3D (H2=1)": make_sphere, | |
| "Torus 3D (H1=2, H2=1)": make_torus, | |
| "Random Noise": make_random, | |
| } | |
| # โโ Shared state โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| _state = {"pts": None, "result": None, "auto_eps": 0.5} | |
| # โโ Visualization Functions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def plot_point_cloud(pts): | |
| fig, ax = plt.subplots(figsize=(6, 6), dpi=100) | |
| fig.patch.set_facecolor("#FAFAFA") | |
| ax.set_facecolor("#FAFAFA") | |
| dim = pts.shape[1] | |
| if dim >= 3: | |
| ax.remove() | |
| ax = fig.add_subplot(111, projection="3d") | |
| ax.scatter(pts[:, 0], pts[:, 1], pts[:, 2], | |
| c=PALETTE["point"], s=12, alpha=0.7, edgecolors="none") | |
| ax.set_xlabel("X", fontsize=9) | |
| ax.set_ylabel("Y", fontsize=9) | |
| ax.set_zlabel("Z", fontsize=9) | |
| ax.xaxis.pane.fill = False | |
| ax.yaxis.pane.fill = False | |
| ax.zaxis.pane.fill = False | |
| else: | |
| ax.scatter(pts[:, 0], pts[:, 1], | |
| c=PALETTE["point"], s=18, alpha=0.7, edgecolors="none") | |
| ax.set_aspect("equal") | |
| ax.set_title(f"Point Cloud ({len(pts)} pts, {dim}D)", fontsize=12, | |
| fontweight="bold", color=PALETTE["dark"]) | |
| if dim < 3: | |
| for spine in ax.spines.values(): | |
| spine.set_visible(False) | |
| ax.tick_params(labelsize=8) | |
| plt.tight_layout() | |
| return fig | |
| def plot_persistence(result): | |
| fig, ax = plt.subplots(figsize=(6, 6), dpi=100) | |
| fig.patch.set_facecolor("#FAFAFA") | |
| ax.set_facecolor("#FAFAFA") | |
| dgms = result["dgms"] | |
| colors = [PALETTE["h0"], PALETTE["h1"], PALETTE["h2"]] | |
| labels = ["H0 (components)", "H1 (loops)", "H2 (voids)"] | |
| all_finite = [] | |
| for dgm in dgms: | |
| finite = dgm[dgm[:, 1] < np.inf] | |
| if len(finite): | |
| all_finite.append(finite) | |
| mx = max(np.vstack(all_finite).max(), 0.1) * 1.15 if all_finite else 1.0 | |
| ax.plot([0, mx], [0, mx], "--", color="#CCC", linewidth=1, zorder=0) | |
| for dim_i, dgm in enumerate(dgms): | |
| finite = dgm[dgm[:, 1] < np.inf] | |
| inf_pts = dgm[dgm[:, 1] == np.inf] | |
| c = colors[dim_i % len(colors)] | |
| lbl = labels[dim_i] if dim_i < len(labels) else f"H{dim_i}" | |
| if len(finite): | |
| ax.scatter(finite[:, 0], finite[:, 1], c=c, s=30, alpha=0.75, | |
| edgecolors="white", linewidths=0.5, label=lbl, zorder=2) | |
| if len(inf_pts): | |
| ax.scatter(inf_pts[:, 0], [mx * 0.95] * len(inf_pts), c=c, s=60, | |
| marker="^", alpha=0.9, edgecolors="white", linewidths=0.5, | |
| zorder=3) | |
| ax.set_xlabel("Birth", fontsize=11) | |
| ax.set_ylabel("Death", fontsize=11) | |
| ax.set_title("Persistence Diagram", fontsize=13, fontweight="bold", | |
| color=PALETTE["dark"]) | |
| ax.legend(fontsize=9, framealpha=0.9) | |
| ax.set_xlim(-0.02 * mx, mx) | |
| ax.set_ylim(-0.02 * mx, mx) | |
| for spine in ax.spines.values(): | |
| spine.set_color("#DDD") | |
| ax.tick_params(labelsize=8) | |
| plt.tight_layout() | |
| return fig | |
| def plot_barcode(result): | |
| fig, ax = plt.subplots(figsize=(8, 5), dpi=100) | |
| fig.patch.set_facecolor("#FAFAFA") | |
| ax.set_facecolor("#FAFAFA") | |
| dgms = result["dgms"] | |
| colors = [PALETTE["h0"], PALETTE["h1"], PALETTE["h2"]] | |
| labels_list = ["H0", "H1", "H2"] | |
| y = 0 | |
| yticks, ytick_labels = [], [] | |
| max_death = 0 | |
| for dgm in dgms: | |
| finite = dgm[dgm[:, 1] < np.inf] | |
| if len(finite): | |
| max_death = max(max_death, finite[:, 1].max()) | |
| cap = max_death * 1.2 if max_death > 0 else 1.0 | |
| for dim_i, dgm in enumerate(dgms): | |
| c = colors[dim_i % len(colors)] | |
| lbl = labels_list[dim_i] if dim_i < len(labels_list) else f"H{dim_i}" | |
| sorted_dgm = dgm[np.argsort(dgm[:, 1] - dgm[:, 0])[::-1]] | |
| for birth, death in sorted_dgm: | |
| d = death if death < np.inf else cap | |
| ax.plot([birth, d], [y, y], color=c, linewidth=2.5, solid_capstyle="round") | |
| if death == np.inf: | |
| ax.plot(d, y, ">", color=c, markersize=5) | |
| y += 1 | |
| if len(dgm): | |
| yticks.append(y - len(dgm) / 2) | |
| ytick_labels.append(lbl) | |
| ax.set_yticks(yticks) | |
| ax.set_yticklabels(ytick_labels, fontsize=10, fontweight="bold") | |
| ax.set_xlabel("Filtration Value", fontsize=11) | |
| ax.set_title("Persistence Barcode", fontsize=13, fontweight="bold", | |
| color=PALETTE["dark"]) | |
| ax.invert_yaxis() | |
| for spine in ["top", "right"]: | |
| ax.spines[spine].set_visible(False) | |
| for spine in ["bottom", "left"]: | |
| ax.spines[spine].set_color("#DDD") | |
| ax.tick_params(labelsize=8) | |
| plt.tight_layout() | |
| return fig | |
| def plot_filtration(pts, epsilon): | |
| if pts.shape[1] > 2: | |
| pts_2d = pts[:, :2] | |
| title_suffix = " (projected to 2D)" | |
| else: | |
| pts_2d = pts | |
| title_suffix = "" | |
| fig, ax = plt.subplots(figsize=(6, 6), dpi=100) | |
| fig.patch.set_facecolor("#FAFAFA") | |
| ax.set_facecolor("#FAFAFA") | |
| dist_mat = squareform(pdist(pts_2d)) | |
| for pt in pts_2d: | |
| circle = MplCircle(pt, epsilon / 2, alpha=0.06, | |
| color=PALETTE["circle"], linewidth=0) | |
| ax.add_patch(circle) | |
| edges = [] | |
| for i in range(len(pts_2d)): | |
| for j in range(i + 1, len(pts_2d)): | |
| if dist_mat[i, j] <= epsilon: | |
| edges.append([pts_2d[i], pts_2d[j]]) | |
| if epsilon > 0 and len(pts_2d) <= 200: | |
| for i in range(len(pts_2d)): | |
| for j in range(i + 1, len(pts_2d)): | |
| if dist_mat[i, j] > epsilon: | |
| continue | |
| for k in range(j + 1, len(pts_2d)): | |
| if dist_mat[i, k] <= epsilon and dist_mat[j, k] <= epsilon: | |
| tri = plt.Polygon( | |
| [pts_2d[i], pts_2d[j], pts_2d[k]], | |
| alpha=0.08, color=PALETTE["h1"], linewidth=0 | |
| ) | |
| ax.add_patch(tri) | |
| if edges: | |
| lc = LineCollection(edges, colors=PALETTE["edge"], alpha=0.35, | |
| linewidths=0.8, zorder=2) | |
| ax.add_collection(lc) | |
| ax.scatter(pts_2d[:, 0], pts_2d[:, 1], c=PALETTE["point"], s=20, | |
| zorder=5, edgecolors="white", linewidths=0.3, alpha=0.9) | |
| n_edges = len(edges) | |
| ax.set_title( | |
| f"Vietoris-Rips Complex \u03b5={epsilon:.3f} | {n_edges} edges{title_suffix}", | |
| fontsize=11, fontweight="bold", color=PALETTE["dark"] | |
| ) | |
| ax.set_aspect("equal") | |
| ax.autoscale() | |
| margin = epsilon * 0.6 + 0.1 | |
| xlim = ax.get_xlim() | |
| ylim = ax.get_ylim() | |
| ax.set_xlim(xlim[0] - margin, xlim[1] + margin) | |
| ax.set_ylim(ylim[0] - margin, ylim[1] + margin) | |
| for spine in ax.spines.values(): | |
| spine.set_visible(False) | |
| ax.tick_params(labelsize=7, colors="#999") | |
| plt.tight_layout() | |
| return fig | |
| def betti_summary(result, eps): | |
| dgms = result["dgms"] | |
| lines = [] | |
| for dim_i, dgm in enumerate(dgms): | |
| alive = sum(1 for b, d in dgm if b <= eps and (d > eps or d == np.inf)) | |
| name = ["Components (H0)", "Loops (H1)", "Voids (H2)"][dim_i] if dim_i < 3 else f"H{dim_i}" | |
| lines.append(f"**\u03b2{dim_i} = {alive}** \u2014 {name}") | |
| total_features = sum(len(dgm) for dgm in dgms) | |
| lines.append(f"\n---\nTotal features: **{total_features}** | \u03b5 = **{eps:.3f}**") | |
| return "\n\n".join(lines) | |
| # โโ Callbacks โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def generate_data(dataset_name, n_points, noise): | |
| try: | |
| n_points = int(n_points) | |
| gen = DATASETS.get(dataset_name, make_circle) | |
| pts = gen(n=n_points, noise=noise) | |
| _state["pts"] = pts | |
| max_dim = 2 if pts.shape[1] >= 3 else 1 | |
| result = ripser(pts, maxdim=max_dim, thresh=3.0) | |
| _state["result"] = result | |
| dist_mat = squareform(pdist(pts)) | |
| np.fill_diagonal(dist_mat, np.inf) | |
| nn_dists = dist_mat.min(axis=1) | |
| auto_eps = float(np.median(nn_dists) * 2) | |
| _state["auto_eps"] = auto_eps | |
| fig_cloud = plot_point_cloud(pts) | |
| fig_diag = plot_persistence(result) | |
| fig_barcode = plot_barcode(result) | |
| fig_filt = plot_filtration(pts, auto_eps) | |
| summary = betti_summary(result, auto_eps) | |
| return fig_cloud, fig_diag, fig_barcode, fig_filt, summary | |
| except Exception as e: | |
| traceback.print_exc() | |
| empty = plt.figure(figsize=(4, 4)) | |
| return empty, empty, empty, empty, f"Error: {e}" | |
| def update_filtration(epsilon): | |
| try: | |
| pts = _state.get("pts") | |
| result = _state.get("result") | |
| if pts is None or result is None: | |
| return None, "*Click Generate & Analyze first*" | |
| fig = plot_filtration(pts, epsilon) | |
| summary = betti_summary(result, epsilon) | |
| return fig, summary | |
| except Exception as e: | |
| traceback.print_exc() | |
| return None, f"Error: {e}" | |
| # โโ CSS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| CUSTOM_CSS = """ | |
| .gradio-container { | |
| max-width: 1200px !important; | |
| font-family: 'Inter', 'Segoe UI', sans-serif; | |
| } | |
| h1 { color: #1D3557; } | |
| .gr-button-primary { | |
| background: linear-gradient(135deg, #E63946, #457B9D) !important; | |
| border: none !important; | |
| } | |
| footer { display: none !important; } | |
| """ | |
| # โโ Gradio App โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| with gr.Blocks(css=CUSTOM_CSS, title="TDA Explorer", theme=gr.themes.Soft()) as demo: | |
| gr.Markdown( | |
| """ | |
| # Topological Data Analysis Explorer | |
| **Explore persistent homology, Betti numbers & simplicial complexes interactively.** | |
| Built by [Dr. Milan Joshi](https://huggingface.co/mlnjsh) | Research: Persistent Homology, TDA for ML | |
| """ | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| gr.Markdown("### Generate Point Cloud") | |
| dataset_dd = gr.Dropdown( | |
| choices=list(DATASETS.keys()), | |
| value="Circle (H1=1)", | |
| label="Dataset", | |
| ) | |
| n_slider = gr.Slider(50, 500, value=150, step=10, label="Points") | |
| noise_slider = gr.Slider(0.0, 0.3, value=0.05, step=0.01, label="Noise") | |
| gen_btn = gr.Button("Generate & Analyze", variant="primary", size="lg") | |
| gr.Markdown("---") | |
| gr.Markdown("### Filtration Control") | |
| eps_slider = gr.Slider( | |
| 0.0, 4.0, value=0.5, step=0.01, | |
| label="Epsilon - connectivity radius", | |
| ) | |
| betti_md = gr.Markdown("*Click Generate & Analyze to start*") | |
| with gr.Column(scale=3): | |
| with gr.Row(): | |
| cloud_plot = gr.Plot(label="Point Cloud") | |
| filt_plot = gr.Plot(label="Filtration Complex") | |
| with gr.Row(): | |
| diag_plot = gr.Plot(label="Persistence Diagram") | |
| barcode_plot = gr.Plot(label="Persistence Barcode") | |
| with gr.Accordion("What is TDA? (click to learn)", open=False): | |
| gr.Markdown( | |
| """ | |
| ### Topological Data Analysis in a Nutshell | |
| TDA studies the **shape** of data using tools from algebraic topology. | |
| | Concept | What it captures | Example | | |
| |---|---|---| | |
| | **H0** (connected components) | Clusters / groups | 3 clusters -> B0 = 3 | | |
| | **H1** (loops / tunnels) | Circular holes | Ring of points -> B1 = 1 | | |
| | **H2** (voids / cavities) | Enclosed volumes | Sphere surface -> B2 = 1 | | |
| #### How it works | |
| 1. Start with a **point cloud** (your data). | |
| 2. Grow balls of radius e around each point. | |
| 3. When balls overlap, connect points to build a **simplicial complex**. | |
| 4. Track which features (components, loops, voids) **appear** and **disappear** as e increases. | |
| 5. Plot births vs deaths: the **persistence diagram**. Features far from the diagonal are real; near diagonal = noise. | |
| #### Betti Numbers | |
| At filtration value e, the **Betti numbers** (B0, B1, B2, ...) count each type of feature currently alive. | |
| #### Why it matters for ML | |
| - **Feature engineering**: Persistent homology gives robust shape descriptors. | |
| - **Outlier detection**: Noise features die quickly; real structure persists. | |
| - **Clustering**: H0 persistence reveals natural cluster counts. | |
| *This demo uses [Ripser](https://ripser.scikit-tda.org/) for fast Vietoris-Rips persistent homology computation.* | |
| """ | |
| ) | |
| # โโ Wiring (no slider update, no demo.load) โโโโโโโโโโโโโโ | |
| gen_btn.click( | |
| fn=generate_data, | |
| inputs=[dataset_dd, n_slider, noise_slider], | |
| outputs=[cloud_plot, diag_plot, barcode_plot, filt_plot, betti_md], | |
| ) | |
| eps_slider.change( | |
| fn=update_filtration, | |
| inputs=[eps_slider], | |
| outputs=[filt_plot, betti_md], | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() | |