scReebTower builds a discrete Reeb graph from a point cloud or a precomputed
affinity graph.
The main entrypoint is sc_reeb_tower.scReebTower(). By default it returns the simplified
graph only and skips optional work such as persistence, point assignment, edge
assignment, and large intermediate outputs. Provide either X or W:
X: a point cloud, from whichscReebTowerbuilds a kNN affinity graphW: a precomputed affinity matrix, if you already have a better graph
You can also optionally provide filter_values to use your own scalar filter
instead of the built-in diffusion-based filters.
You can also optionally provide t to apply diffusion smoothing to the point
cloud before the affinity graph is built. Set t=0 to disable smoothing, or
t="auto" to choose a smoothing time from the PHATE-style Von Neumann entropy
elbow. This preprocessing step affects the full downstream pipeline. By
default, smoothing uses the historical fixed-operator mode,
X_smoothed = P^t X. To run iterative diffusion condensation, set
smoothing_mode="condensation" and choose the tower level with ell; this
recomputes the affinity graph and diffusion operator at every level,
X^{(r+1)} = P_r^t X^{(r)}. You can also pass ell="auto" to stop when
the normalized mean displacement between condensation levels stabilizes.
You can also optionally provide k_smooth to set the neighborhood size used
only for the smoothing diffusion operator. If omitted, it defaults to
ceil(sqrt(n_samples)) when smoothing is enabled. The final scReebTower graph still
uses the public k.
The scalar filter is the first nontrivial diffusion eigenfunction computed
separately on each connected component of the affinity graph. You can choose a
later nontrivial eigenfunction with diffusion_eigen_index. As an
alternative, you can use a rooted PHATE-style potential-distance filter with
filter_method="rooted_potential_distance" or a rooted diffusion
shortest-path filter with
filter_method="rooted_diffusion_shortest_path".
For the rooted potential-distance filter, scReebTower:
- builds the row-stochastic diffusion operator on each connected component
- selects diffusion time with a PHATE-style Von Neumann entropy knee when
diffusion_time="auto" - computes
P^t - chooses a root by two-sweep Dijkstra on the original sparse graph with edge
costs derived from
P^t - uses rooted PHATE-style potential distance as the scalar filter
For the rooted diffusion shortest-path filter, scReebTower:
- builds the row-stochastic diffusion operator on each connected component
- builds a diffusion cost graph on the original sparse graph directly from
P - chooses a root by two-sweep Dijkstra on that cost graph
- uses shortest-path distance from the root on that cost graph as the scalar filter
Install with pip:
pip install sc-reeb-towerAfter installation, both the main pipeline and plotting are available directly:
from sc_reeb_tower import (
scReebTower,
plot_scReebTower,
extract_cellular_trajectory,
enumerate_terminal_cellular_trajectories,
)from scReebTower import scReebTower
result = scReebTower(X)
graph = result["simplified"]
nodes = graph["nodes"]
edges = graph["edges"]
beta1 = graph["beta1"]Or, using a precomputed affinity matrix:
result = scReebTower(W=affinity_matrix)Or, using a precomputed affinity matrix and a custom scalar filter:
result = scReebTower(W=affinity_matrix, filter_values=my_filter)Default return:
result["primary_graph"] == "simplified"result["simplified"]["nodes"]result["simplified"]["edges"]result["simplified"]["keep_ids"]result["simplified"]["beta1"]result["simplified"]["comp_count"]
If you want the raw graph instead:
result = scReebTower(X, simplify=False)
raw = result["raw"]
nodes = raw["nodes"]
edges = raw["edges"]If you want to plot the result, request the raw graph and intermediates:
from scReebTower import scReebTower, plot_scReebTower
result = scReebTower(X, return_raw=True, return_intermediates=True)
fig, node_pos = plot_scReebTower(
pts=embedding_2d,
simp_edges=result["simplified"]["edges"],
keep_ids=result["simplified"]["keep_ids"],
reeb_nodes=result["raw"]["nodes"],
step_vertices=result["intermediates"]["step_vertices"],
step_comp_ids=result["intermediates"]["step_comp_ids"],
uniq_v=result["intermediates"]["prep_state"]["uniq_v"],
)scReebTower() returns a structured dictionary with stable top-level keys:
primary_graph:"simplified"or"raw"simplified: simplified graph outputs whensimplify=Trueraw: raw graph outputs when requested, or whensimplify=Falseintermediates: internal construction outputs when requested
Each section only contains the fields you asked for, but the top-level layout stays stable.
result = scReebTower(
X=X,
# or W=affinity_matrix,
# or W=affinity_matrix, filter_values=my_filter,
t=0,
ell=None,
k_smooth=None,
smoothing_mode="fixed_operator",
ell_max=20,
condensation_tol=1e-3,
condensation_patience=2,
t_max=100,
diffusion_eigen_index=1,
filter_method="diffusion_eigenfunction",
diffusion_time="auto",
return_raw=True,
return_edge_lengths=True,
return_simp_persistence=True,
return_raw_persistence=True,
return_point_assignment=True,
return_edge_assignment=True,
return_cellular_decomposition=True,
return_intermediates=True,
)return_raw=TrueInclude the raw graph underresult["raw"].W=affinity_matrixUse a precomputed affinity matrix instead of building one fromX. Provide exactly one ofXorW.filter_values=my_filterUse a user-supplied scalar filter on graph vertices. When provided,scReebTowerskips built-in filter construction and uses this vector directly.t=0Apply diffusion smoothing to the input point cloud before graph construction. Set0to disable smoothing, pass an integert > 0to use diffusion smoothing, or passt="auto"to choose a smoothing time from the PHATE-style entropy elbow. In condensation mode,tis the diffusion power applied at each tower level.ell=NoneNumber of diffusion-condensation levels whensmoothing_mode="condensation". For example,ell=3, t=2applies three condensation levels, each usingP_ell^2before recomputing the diffusion operator for the next level. Passell="auto"to iterate until the condensation process stabilizes. If omitted in condensation mode,elldefaults to1when smoothing is enabled.smoothing_mode="fixed_operator"Smoothing mode used whent > 0ort="auto". The default"fixed_operator"computesX_smoothed = P^t Xusing one diffusion operator built from the original point cloud, preserving historical behavior. Set"condensation"to recompute the smoothing affinity graph and diffusion operator at every tower level:X^{(r+1)} = P_r^t X^{(r)}.ell_max=20Maximum number of condensation levels whenell="auto".condensation_tol=1e-3Stabilization threshold forell="auto". At condensation levelr, scReebTower computesdelta_r = mean_i ||X_i^(r+1) - X_i^(r)||_2 / median_i ||X_i^(0) - median(X^(0))||_2. A level is considered stable whendelta_r < condensation_tol.condensation_patience=2Number of consecutive stable condensation levels required beforeell="auto"stops. If stabilization is not reached, condensation stops atell_max. The chosen level count and stop reason are returned inresult["intermediates"]["condensation_levels"]andresult["intermediates"]["condensation_stop_reason"].k_smooth=NoneNeighborhood size used only to build the smoothing diffusion operator. If omitted and smoothing is enabled, the default isceil(sqrt(n_samples)). This is separate from the publickused for the final Reeb graph.t_max=100Maximum time tested by the entropy-knee heuristic whent="auto".diffusion_eigen_index=1Use the first nontrivial diffusion eigenfunction. Set2,3, ... to use later nontrivial diffusion eigenfunctions. Ignored whenfilter_valuesis provided.filter_method="diffusion_eigenfunction"Use the default diffusion-eigenfunction scalar filter, or setfilter_method="rooted_potential_distance"to use a rooted PHATE-style potential-distance filter, orfilter_method="rooted_diffusion_shortest_path"to use shortest-path distance from a root on the diffusion cost graph built directly fromP. Ignored whenfilter_valuesis provided.diffusion_time="auto"When usingfilter_method="rooted_potential_distance", select diffusion time with the entropy-knee heuristic. You can also pass an explicit integer. Ignored forrooted_diffusion_shortest_pathand whenfilter_valuesis provided.return_edge_lengths=TrueReturn filter-space edge lengths in the graph sections that are present.return_simp_persistence=TrueReturn simplified graph persistence underresult["simplified"]["persistence"].return_raw_persistence=TrueReturn raw graph persistence underresult["raw"]["persistence"].return_point_assignment=TrueReturn node-level point ownership in the active graph section.return_edge_assignment=TrueReturn edge-level support and ownership outputs in the graph sections.return_cellular_decomposition=TrueReturn a mixed disjoint partition of all points onto simplified node cells and simplified edge cells. Requiressimplify=True.return_intermediates=TrueReturn matrices and arrays needed for debugging or plotting.
Simplified graph plus raw graph:
result = scReebTower(X, return_raw=True)
simplified = result["simplified"]
raw = result["raw"]Simplified graph plus persistence:
result = scReebTower(X, return_simp_persistence=True)
h1 = result["simplified"]["persistence"]["h1"]Use the second nontrivial diffusion eigenfunction:
result = scReebTower(X, diffusion_eigen_index=2)Use the rooted potential-distance filter:
result = scReebTower(
X,
filter_method="rooted_potential_distance",
diffusion_time="auto",
)Use rooted diffusion shortest-path:
result = scReebTower(
X,
filter_method="rooted_diffusion_shortest_path",
)Use a custom scalar filter:
result = scReebTower(W=affinity_matrix, filter_values=my_filter)Simplified graph plus node assignment:
result = scReebTower(X, return_point_assignment=True)
point_assignment = result["simplified"]["point_assignment"]
node_points = result["simplified"]["node_points"]
node_support_points = result["simplified"]["node_support_points"]Simplified graph plus edge assignment:
result = scReebTower(X, return_edge_assignment=True)
edge_points = result["simplified"]["edge_points"]
edge_support_points = result["simplified"]["edge_support_points"]
point_edge_assignment = result["simplified"]["point_edge_assignment"]
point_edges = result["simplified"]["point_edges"]Simplified graph plus cellular decomposition:
result = scReebTower(X, return_cellular_decomposition=True)
cell_kind = result["simplified"]["cell_assignment_kind"]
cell_id = result["simplified"]["cell_assignment_id"]
node_cells = result["simplified"]["node_cells"]
edge_cells = result["simplified"]["edge_cells"]Raw persistence without returning raw edges explicitly:
result = scReebTower(X, return_raw_persistence=True)
raw_pd = result["raw"]["persistence"]Inspect the filter metadata used during construction:
result = scReebTower(
X,
filter_method="rooted_potential_distance",
return_intermediates=True,
)
meta = result["intermediates"]["filter_metadata"]
root_ids = meta["component_root_indices"]
root_pairs = meta["component_root_pairs"]
times = result["intermediates"]["filter_aux_values"]For the default diffusion-eigenfunction filter, filter_aux_values contains
the selected per-component diffusion eigenvalues and is also available as
result["intermediates"]["diffusion_eigenvalues"]. For the rooted
potential-distance filter, filter_aux_values contains the selected
per-component diffusion times.
scReebTower() supports three scalar filter constructions:
filter_method="diffusion_eigenfunction"filter_method="rooted_potential_distance"filter_method="rooted_diffusion_shortest_path"
Or you can bypass filter construction entirely by passing
filter_values=... into scReebTower().
diffusion_eigenfunction:
- computed separately on each connected component of the affinity graph
- uses the
diffusion_eigen_index-th nontrivial diffusion eigenfunction - ignores
diffusion_time
rooted_potential_distance:
- computed separately on each connected component of the affinity graph
- uses PHATE-style potential distance from an automatically selected root
- uses
diffusion_time, or selects it automatically with a PHATE-style entropy knee whendiffusion_time="auto" - stores per-component root information in
result["intermediates"]["filter_metadata"]whenreturn_intermediates=True
rooted_diffusion_shortest_path:
- computed separately on each connected component of the affinity graph
- builds the row-stochastic diffusion operator
P - builds a diffusion cost graph on the original sparse graph using
-log(0.5 * (P_ij + P_ji)) - chooses a root by two-sweep Dijkstra on that cost graph
- uses shortest-path distance from the root on that cost graph as the scalar filter
- ignores
diffusion_time
Important note:
- changing
diffusion_timeaffectsrooted_potential_distance - it does not affect
rooted_diffusion_shortest_path - it does not meaningfully change the default single-eigenfunction filter, because powering the diffusion operator changes eigenvalues but not eigenvectors
Point assignment is opt-in via return_point_assignment=True.
There are two related but different node-level outputs:
node_points: an ownership partition, one simplified/raw node id per pointnode_support_points: the intrinsic local support of each node
If you want the points supporting a trajectory through the simplified graph,
use edge_support_points together with a graph path in
result["simplified"]["edges"] and take the union of the edge supports along
that path.
When simplify=True:
node_support_pointsare inherited from the kept raw nodesnode_pointsare a full ownership partition of the dataset onto simplified nodes- ownership is computed by graph Voronoi on the original point graph using shortest-path distance on the symmetrized diffusion operator
When simplify=False:
- points are assigned to raw Reeb nodes
Returned fields:
point_assignment: node index per pointnode_points: ownership partition of points per nodenode_support_points: intrinsic support points per node
Edge assignment is opt-in via return_edge_assignment=True.
Returned fields:
edge_points: ownership partition of points per edgepoint_edge_assignment: one edge id per pointedge_support_points: intrinsic support points per edgepoint_edges: overlapping edge-support memberships per point
Raw edge assignment rule:
- for a raw edge connecting
(step s, comp c)to(step s+1, comp c_next), points are assigned if they lie in the connected bridging region across levelss, s+1, s+2that touches both endpoint node supports
Simplified edge assignment rule:
- simplified edge support is inherited from the contracted raw path behind that simplified edge
- inherited support includes raw edge supports on that path plus the support points of contracted interior raw nodes
- simplified edge ownership is a full partition of the dataset onto simplified edges
- ownership is computed by graph Voronoi on the original point graph using shortest-path distance on the symmetrized diffusion operator
Cellular decomposition is opt-in via return_cellular_decomposition=True and
is only defined on the simplified graph.
It is a mixed disjoint partition of the whole dataset onto:
- simplified node cells, corresponding to 0-cells
- simplified edge cells, corresponding to 1-cells
Returned fields:
cell_assignment_kind: per-point"node"or"edge"cell_assignment_id: per-point simplified node id or simplified edge idnode_cells: list of point index arrays, one per simplified nodeedge_cells: list of point index arrays, one per simplified edge
This decomposition uses the same graph metric as the ownership assignments: shortest-path distance on the symmetrized diffusion operator, comparing node supports against edge supports directly.
Helper for extracting points from selected cells:
from scReebTower import extract_cellular_trajectory
result = scReebTower(X, return_cellular_decomposition=True)
simplified = result["simplified"]
# Mixed cell indexing: nodes first, then edges.
points = extract_cellular_trajectory(simplified, cell_indices=[0, 3, 7])If there are n simplified nodes, then:
- cell indices
0 .. n-1are node cells - cell indices
n .. n + m - 1are edge cells
Helper for enumerating terminal cellular trajectories:
from scReebTower import enumerate_terminal_cellular_trajectories
result = scReebTower(
X,
return_edge_lengths=True,
return_cellular_decomposition=True,
)
trajectories = enumerate_terminal_cellular_trajectories(result["simplified"])
top = trajectories[0]
top["node_path"]
top["edge_path"]
top["cell_indices"]
top["graph_length"]Here, a terminal simplified node means a node whose multigraph degree is not
equal to 2, so trajectories run between leaf tips, branch points, isolated
nodes, or loop anchors.
Edge lengths are opt-in via return_edge_lengths=True.
result["simplified"]["edge_lengths"]: one filter-space length per simplified edgeresult["raw"]["edge_lengths"]: one filter-space length per raw edge
For an edge (u, v), the length is the absolute difference between the
midpoint slice values of its endpoint nodes.
Persistence is opt-in via return_simp_persistence=True and
return_raw_persistence=True.
This is graph-filtration persistence on the returned multigraph:
- all vertices are born at filtration value
0 - edges are added in nondecreasing order of edge length
- an edge that merges two connected components creates an
H0death - an edge that closes a cycle creates an essential
H1class with deathinf
Returned dictionary format:
h0: finiteH0pairs with shape(m, 2)h0_essential: essentialH0pairs with shape(c, 2)h1: essentialH1pairs with shape(k, 2)
Example:
result = scReebTower(X, return_simp_persistence=True)
pd = result["simplified"]["persistence"]
h0 = pd["h0"]
h1 = pd["h1"]This is not higher-dimensional simplicial persistence; it is persistence of the edge-weighted graph itself.
plot_scReebTower() overlays the simplified graph on a 2D or 3D embedding that you
compute separately. It works with the standard package install; there is no
separate plotting extra or second install step.
from scReebTower import scReebTower, plot_scReebTower
result = scReebTower(X, return_raw=True, return_intermediates=True)
fig, node_pos = plot_scReebTower(
pts=embedding_2d,
simp_edges=result["simplified"]["edges"],
keep_ids=result["simplified"]["keep_ids"],
reeb_nodes=result["raw"]["nodes"],
step_vertices=result["intermediates"]["step_vertices"],
step_comp_ids=result["intermediates"]["step_comp_ids"],
uniq_v=result["intermediates"]["prep_state"]["uniq_v"],
)Practical notes:
ptsmust be a 2D or 3D embedding in the same row order asX- if you ran
scReebTower(W=...),ptsmust instead match the row order ofW - 2D multiedges are drawn as curved arcs so parallel edges are legible
- cycle edges are colored crimson when
color_cycles=True - node labels show multigraph degree when
label_degree=True - the function returns
(fig, node_pos)so you can save or modify the figure
plot_scReebTower() assumes the embedding array pts is aligned row-for-row with the
nodes of the graph passed to scReebTower(): the rows of X when using X=..., or
the rows of W when using W=....
How node membership is recovered for plotting:
- each raw Reeb node is identified by
(step, comp)inreeb_nodes step_vertices[step]gives the compacted active vertex indices in that slicestep_comp_ids[step]gives the component id for each active vertex- compacted indices map back to original point indices through
prep_state["uniq_v"] - those original indices are used to index into
ptsand compute node centroids
If you shuffle or subset your data before embedding, apply the same
transformation to both X and pts, or remap indices accordingly.
Typical calls:
- topology only:
result = scReebTower(X) - topology plus raw graph:
result = scReebTower(X, return_raw=True) - topology plus persistence:
result = scReebTower(X, return_simp_persistence=True) - topology plus node ownership:
result = scReebTower(X, return_point_assignment=True) - topology plus edge ownership:
result = scReebTower(X, return_edge_assignment=True) - topology plus plotting support:
result = scReebTower(X, return_raw=True, return_intermediates=True)