TDA-Explorer / app.py
mlnjsh's picture
Upload app.py with huggingface_hub
2ba9241 verified
Raw
History Blame Contribute Delete
17.6 kB
"""
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()