Optimal cycles

In this example we will

  • generate a point cloud

  • generate a collection of subsets (this can be viewed as a cover, and also as a hypergraph)

  • compute the homology of the associated Dowker complex

  • analyze cycle representatives

  • plot cycle representatives

import oat_python as oat

import plotly.graph_objects as go
import plotly.express as px
import numpy as np
import sklearn
import sklearn.metrics
from sklearn.neighbors import NearestNeighbors
import itertools

Generate a point cloud

Set parameter values

n_points            =   60
maxdis              =   None
maxdim              =   1

Generate a point cloud consisting of three noisy circles

points               =   []
for seed in range(3):
    circle          =   oat.point_cloud.annulus(n_points=n_points, inner_radius=1, outer_radius=2.5, random_seed=seed)
    circle[:,0]     +=  2 * np.cos( seed * 2 * np.pi / 4)
    circle[:,1]     +=  2 * np.sin( seed * 2 * np.pi / 4)
    points.append( circle )
points               =   np.concatenate(points)

dismat = sklearn.metrics.pairwise_distances(points)
dismat = ( dismat + dismat.T ) / 2

Plot the point cloud

trace = go.Scatter(x=points[:,0],y=points[:,1], mode="markers")
fig = go.Figure([trace])
fig.update_layout( title=dict(text="Circle with noise"), height=650 )
fig


Choose a cover

radius_neighbor =   1.2; # hyperedges will be neighborhoods of vertices, with this radius
radius_net      =   1.0; # we'll make epsilon net with this value of epsilon

#   COMPUTE AN EPSILON NET
net, _          =   oat.dissimilarity.farthest_point_sampling( # the algorithm uses farthest point sampling
                        metric_space        =   dict( point_cloud = points ),
                        stopping_condition  =   dict( epsilon = radius_net )
                    )

#   PLOT THE COVER

data            =   []
data.append( go.Scatter(x=points[:,0],y=points[:,1], mode="markers", name="Cloud", showlegend=True)                                               )
data.append( go.Scatter(x=points[net,0],y=points[net,1], mode="markers", marker=dict(symbol="triangle-up", size=10), name="Net", showlegend=True) )


for counter, v in enumerate(net):
    point = points[v]
    trace = oat.plot.ball_2d( point[0], point[1], radius=radius_neighbor, n_points=100 )
    trace.update( opacity=0.2, name=f"Cover {counter}" )
    data.append(trace)

fig = go.Figure(data)
fig.update_layout(
    title=dict(text="Hyperedges"),
    height=650,
    yaxis=dict(
        scaleanchor="x",  # Anchor y-axis scaling to x-axis
        scaleratio=1.0    # Set the aspect ratio (1.0 for square aspect)
    ),
)
fig


Compute homology

We’ll compute the homology of - the dual hypergraph; that is, the hypergraph where vertices are balls, and for each vertex v we have a hyperedge that contains every ball to which v belongs - equivalently, the witness complex where every point is a witness and net points are landmarks

The homology solver only accepts hypergraphs represented by a list of lists of integers, currently. If your hypergraph has a different format (e.g., if vertices are strings), then you can use some built-in tools to help translate back and forth between this format and list-of-list format; see the [rbs_reduced](rbs_reduced.ipynb) notebook for examples.

Format the cover as a family of sorted lists:

# data structure holding the whole cloud
net_wrapper         =   NearestNeighbors(n_neighbors=1, algorithm='ball_tree').fit( points[net] )
# witness complex where all points are witnesses, and net points are landmarks
cover               =   [ sorted(list(x)) for x in net_wrapper.radius_neighbors( points, radius=radius_neighbor, return_distance=False, ) ]

Factor the boundary matrix to compute homology:

decomposition       =   oat.core.dowker.BoundaryMatrixDecompositionDowker(
                            dowker_simplices            =   cover,
                            max_homology_dimension      =   2
                        )

Inspect the Betti numbers

decomposition.betti_numbers()
[1, 4, 0]

Place a cycle basis for homology into a dataframe:

homology            =   decomposition.homology()
homology
dimension cycle_representative cycle_representative_nonzero_coefficient_count unique_simplex_id
0 0 simplex coefficient 0 (0,) 1 1 (0,)
1 1 simplex coefficient 0 (0, 15) -... 12 (7, 15)
2 1 simplex coefficient 0 (0, 10) -... 16 (7, 16)
3 1 simplex coefficient 0 (0, 15) -1 ... 7 (8, 15)
4 1 simplex coefficient 0 (0, 9) 1 ... 3 (9, 25)


Inspect a cycle representative

homology["cycle_representative"][1]
simplex coefficient
0 (0, 15) -1
1 (0, 17) 1
2 (2, 13) 1
3 (2, 14) -1
4 (3, 8) 1
5 (3, 11) -1
6 (4, 11) 1
7 (4, 17) -1
8 (6, 8) -1
9 (6, 14) 1
10 (7, 13) -1
11 (7, 15) 1


Plot cycles

#   DATA FOR THE POINT CLOUD

data                    =   []
data.append( go.Scatter(x=points[:,0],y=points[:,1], mode="markers", name="Cloud", showlegend=True)                                               )
data.append( go.Scatter(x=points[net,0],y=points[net,1], mode="markers+text", marker=dict(symbol="triangle-up", size=10), name="Net", showlegend=True, text=[str(x) for x in range(len(net))],  textposition='bottom center',  ) )

#   DATA FOR THE CYCLE

colors                  =   px.colors.qualitative.Plotly # specifies a (discrete) sequence of colors, represented by a list of strings

for rownum, row in homology.iterrows():
    if row["dimension"] != 1: continue

    trace_cycle         =   oat.plot.trace_2d_for_edges(
                                edges       =   row["cycle_representative"].simplex,
                                points      =   points[net],
                                name        =   f"Cycle {rownum}",
                            )
    data.append(trace_cycle) # add the trace to our list

#   PLOT

fig = go.Figure( data )
fig.update_layout(title="Coefficients still appear in the hover data", height=650 )
fig


Optimize

Within homology class

This method will find a minimal cycle representative within the same homology class

optimal     =   decomposition.optimize_cycle(
                    unique_simplex_id=[7,15],
                    problem_type="preserve homology class"
                )
optimal
cost nnz chain
type of chain
initial_cycle 12.0 12 simplex coefficient 0 (7, 15) ...
optimal_cycle 10.0 10 simplex coefficient 0 (15, 26) -...
surface_between_cycles NaN 4 simplex coefficient 0 (7, 15, 26) ...
difference in essential chains NaN 6 simplex coefficient 0 (15, 26) -...
Ax + z - y NaN 6 simplex coefficient 0 (15, 26) -...


Inspect the initial cycle

optimal["chain"]["initial_cycle"]
simplex coefficient
0 (7, 15) 1
1 (7, 13) -1
2 (6, 14) 1
3 (6, 8) -1
4 (4, 17) -1
5 (4, 11) 1
6 (3, 11) -1
7 (3, 8) 1
8 (2, 14) -1
9 (2, 13) 1
10 (0, 17) 1
11 (0, 15) -1


Inspect the optimal cycle

optimal["chain"]["optimal_cycle"]
simplex coefficient
0 (15, 26) -1
1 (14, 26) 1
2 (6, 14) 1
3 (6, 8) -1
4 (4, 17) -1
5 (4, 11) 1
6 (3, 11) -1
7 (3, 8) 1
8 (0, 17) 1
9 (0, 15) -1


Compare cycles visually

#   DATA FOR THE POINT CLOUD

data                    =   []
data.append( go.Scatter(x=points[:,0],y=points[:,1], mode="markers", name="Cloud", showlegend=True)                                               )
data.append( go.Scatter(x=points[net,0],y=points[net,1], mode="markers+text", marker=dict(symbol="triangle-up", size=10), name="Net", showlegend=True, text=[str(x) for x in range(len(net))],  textposition='bottom center',  ) )

#   DATA FOR THE CYCLE

colors                  =   px.colors.qualitative.Plotly # specifies a (discrete) sequence of colors, represented by a list of strings

for cycle_num, cycle_name in enumerate(["optimal_cycle", "initial_cycle"]):

    cycle_color         =   colors[ cycle_num % len(colors) ]

    for entrynum, entry in optimal["chain"][cycle_name].iterrows():
        edge            =   entry["simplex"]
        coefficient     =   entry["coefficient"]

        trace           =   oat.plot.trace_2d_for_edge( edge=edge, points=points[net] )
        trace.update( name=cycle_name, text=f"simplex {edge}<br>linear coefficent {coefficient}", opacity=0.7, line=dict(color=cycle_color,)) # customize appearance
        trace.update( legendgroup=cycle_num) # group edges that belong to the same cycle
        trace.update( showlegend = entrynum==0 )
        data.append(trace) # append to the data group

#   PLOT

fig = go.Figure( data )
fig.update_layout(title="Initial and optimal cycles", height=650 )
fig


Across homology classes

The following method allows us to optimize the cycle representative by adding not only boundaries, but cycle represntatives for other homology classes.

optimal     =   decomposition.optimize_cycle(
                    unique_simplex_id       =   (7,15),
                    problem_type            =   "preserve homology basis (once)"
                )
optimal
cost nnz chain
type of chain
initial_cycle 12.0 12 simplex coefficient 0 (7, 15) ...
optimal_cycle 4.0 4 simplex coefficient 0 (15, 26) -...
surface_between_cycles NaN 7 simplex coefficient 0 (8, 15, 22) ...
difference in essential chains NaN 16 simplex coefficient 0 (15, 26) ...
Ax + z - y NaN 11 simplex coefficient 0 (15, 26) ...


Compare cycles visually

#   DATA FOR THE POINT CLOUD

data                    =   []
data.append( go.Scatter(x=points[:,0],y=points[:,1], mode="markers", name="Cloud", showlegend=True)                                               )
data.append( go.Scatter(x=points[net,0],y=points[net,1], mode="markers+text", marker=dict(symbol="triangle-up", size=10), name="Net", showlegend=True, text=[str(x) for x in range(len(net))],  textposition='bottom center',  ) )

#   DATA FOR THE CYCLE

colors                  =   px.colors.qualitative.Plotly # specifies a (discrete) sequence of colors, represented by a list of strings

for cycle_num, cycle_name in enumerate(["optimal_cycle", "initial_cycle"]):

    cycle_color         =   colors[ cycle_num % len(colors) ]

    for entrynum, entry in optimal["chain"][cycle_name].iterrows():
        edge            =   entry["simplex"]
        coefficient     =   entry["coefficient"]

        trace           =   oat.plot.trace_2d_for_edge( edge=edge, points=points[net] )
        trace.update( name=cycle_name, text=f"simplex {edge}<br>linear coefficent {coefficient}", opacity=0.7, line=dict(color=cycle_color,)) # customize appearance
        trace.update( legendgroup=cycle_num) # group edges that belong to the same cycle
        trace.update( showlegend = entrynum==0 )
        data.append(trace) # append to the data group

#   PLOT

fig = go.Figure( data )
fig.update_layout(title="Initial and optimal cycles", height=650 )
fig


Total running time of the script: (0 minutes 0.182 seconds)

Gallery generated by Sphinx-Gallery