R=DV and other matrix decompositions

R = DV factorization is a standard tool to compute persistent homology. OAT obtains R=DV factorizations by way of Umatch decompositions. This tutorial shows how to obtain

  • Umatch decompositions

  • R=DV factorizations

  • R=DV factorizations of the anti-transpose of D (for persistent cohomology)

  • RU=D factorizations

import oat_python as oat

from fractions import Fraction
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import plotly.graph_objects as go
from scipy.sparse import csr_matrix

Setup

Define a function to check equality of sparse matrices

def sparse_matrices_equal(A, B):
    """Check if two scipy sparse matrices are equal."""
    # Check shape first
    if A.shape != B.shape:
        return False
    # Subtract and check if all elements are zero
    diff = (A != B).nnz == 0
    return diff

Generate a point cloud

points               =   oat.point_cloud.sphere_or_slice_spiral(n_points=8, noise_scale=0.07, random_seed=0)

Plot the point cloud

trace               =   go.Scatter3d(x=points[:,0],y=points[:,1],z=points[:,2], mode="markers", marker=dict(opacity=1, size=4, color=points[:,2], colorscale="Aggrnyl"))
fig                 =   go.Figure(data=trace)
fig.update_layout(title=dict(text="Point cloud"), height=800,width=850)
fig


Compute persistent homology

We compute persistent homology by factoring the boundary matrix. The following cell generates a sparse distance matrix and feeds it to the persistent homology solver. The result is a decomposition boundary matrix. We will extract information from this matrix in the following cells.

Prepare a dissimilarity matrix

# Calculate the minimum enclosing radius. There is a theorem that states all
# homology vanishes above this filtration parameter, so we can exclude
# simplices whose diameter is larger than this value from the complex.
enclosing_radius        =   oat.dissimilarity.enclosing_radius_for_points(points)

# Format the dissimilarity matrix.
# This produces a dissimilarity matrix where all values over
# enclosing_radius + 0.0000000001 are removed. Adding 0.0000000001 avoids
# problems due to numerical error.
dissimilarity_matrix    =   oat.dissimilarity.sparse_matrix_for_points(
                                points                   =   points,
                                max_dissimilarity        =   enclosing_radius + 0.00000000001,
                            )

Create and decompose the boundary matrix

decomposition           =   oat.core.vietoris_rips.BoundaryMatrixDecomposition(
                                dissimilarity_matrix    =   dissimilarity_matrix,
                            )

Compute the boundary of a chain

chain                   =   pd.DataFrame([
                                { "simplex":(0,1,2), "coefficient":Fraction(1, 1)  },
                                { "simplex":(4,5,6), "coefficient":Fraction(1, 1)  },
                            ])
boundary_matrix         =   decomposition.boundary_matrix_oracle()
boundary                =   boundary_matrix.boundary_for_chain( chain )
oat.plot.display_dataframes_side_by_side(
    chain, boundary,
    titles=  ["Chain", "Boundary for this chain"]
)

Chain

simplex coefficient
0 (0, 1, 2) 1
1 (4, 5, 6) 1

Boundary for this chain

simplex filtration coefficient
0 (0, 1) 0.573701 1
1 (0, 2) 0.974061 -1
2 (1, 2) 1.041700 1
3 (5, 6) 1.121170 1
4 (4, 5) 1.476147 1
5 (4, 6) 1.503094 -1


Sort the simplices in the boundary by lexicographic order

boundary.sort_values(inplace=True, by="simplex")
boundary
simplex filtration coefficient
0 (0, 1) 0.573701 1
1 (0, 2) 0.974061 -1
2 (1, 2) 1.041700 1
4 (4, 5) 1.476147 1
5 (4, 6) 1.503094 -1
3 (5, 6) 1.121170 1


Compute a coboundary

cochain                 =   pd.DataFrame([
                                { "simplex":(0,1), "coefficient":Fraction(1, 1)  },
                            ])
coboundary              =   boundary_matrix.coboundary_for_cochain( cochain )
oat.plot.display_dataframes_side_by_side(
    cochain, coboundary,
    titles=  ["Cochain", "Coboundary for this cochain"]
)

Cochain

simplex coefficient
0 (0, 1) 1

Coboundary for this cochain

simplex filtration coefficient
0 (0, 1, 2) 1.041700 1
1 (0, 1, 4) 1.493781 1
2 (0, 1, 3) 1.560769 1
3 (0, 1, 5) 1.643623 1
4 (0, 1, 6) 1.790893 1


A boundary matrix

D                       =   decomposition.boundary_matrix_as_csr()

plt.figure(figsize=(6, 6))
plt.spy(D, markersize=2, color="orange")
plt.title("D (boundary matrix)", y=-0.18)  # Move title to the bottom
plt.tight_layout()
plt.show()
D (boundary matrix)

The simplex corresponding to each row and column of the boundary matrix can be obtained as follows.

  • This list includes all simplices of dimension <= max_homology_dimension, and all negative simplices of dimension max_homology_dimension + 1.

  • Simplices are sorted first by dimension, then by filtration value, then lexicographically.

decomposition.boundary_matrix_indices_df()
simplex filtration
0 (0,) 0.000000
1 (1,) 0.000000
2 (2,) 0.000000
3 (3,) 0.000000
4 (4,) 0.000000
5 (5,) 0.000000
6 (6,) 0.000000
7 (7,) 0.000000
8 (6, 7) 0.551279
9 (0, 1) 0.573701
10 (0, 2) 0.974061
11 (1, 2) 1.041700
12 (5, 7) 1.049453
13 (2, 5) 1.088970
14 (5, 6) 1.121170
15 (1, 4) 1.170000
16 (3, 6) 1.181233
17 (1, 5) 1.294119
18 (0, 3) 1.299655
19 (4, 7) 1.299778
20 (2, 6) 1.329181
21 (2, 3) 1.438239
22 (4, 5) 1.476147
23 (0, 4) 1.493781
24 (4, 6) 1.503094
25 (3, 4) 1.529175
26 (3, 7) 1.545687
27 (1, 3) 1.560769
28 (0, 5) 1.643623
29 (2, 7) 1.651619
30 (1, 6) 1.719551
31 (0, 6) 1.790893


A differential Umatch decomposition

A differential Umatch decomposition is a tuple of matrices \((J,M,D,J)\), where

  • \(D\) is a boundary matrix

  • \(J\) is a square upper triangular matrix with ones on the diagonal

  • \(M\) is a generalized matching matrix

See Differential Umatch Decomposition for definitions and background. Umatch decompositions can be used to obtain

  • \(R=DV\) decompositions by setting \(V=J\) and \(R=DJ\)

  • \(RU=D\) decompositions by setting \(U = J^{-1}\).

  • \(R=WD\) decompositions by setting \(W=J^{-1}\) and \(R=J^{-1}D\)

    • this is the calculation used to obtain persistent cohomology

    • it is commonly described as \(R=DV\) decomposition of the antitranspose \(D^\perp\)

  • \(YR=D\) decompositions by setting \(Y = J\).

D                       =   decomposition.boundary_matrix_as_csr()
J                       =   decomposition.differential_comb_as_csr()
M                       =   decomposition.generalized_matching_matrix_as_csr()

D.data                  =   D.data.astype(float)
J.data                  =   J.data.astype(float)
M.data                  =   M.data.astype(float)


#   plot sparsity patterns
fig, axs = plt.subplots(1,4)
fig.set_figwidth(12)


axs[0].spy(J,   precision=0, marker=None, markersize=1, color="orange", aspect='equal', origin='upper')
axs[1].spy(M,   precision=0, marker=None, markersize=1, color="orange", aspect='equal', origin='upper')
axs[2].spy(D,   precision=0, marker=None, markersize=1, color="orange", aspect='equal', origin='upper')
axs[3].spy(J,   precision=0, marker=None, markersize=1, color="orange", aspect='equal', origin='upper')
axs[0].set_title("J", y=-0.2)
axs[1].set_title("M", y=-0.2)
axs[2].set_title("D", y=-0.2)
axs[3].set_title("J", y=-0.2)
plt.tight_layout()
J, M, D, J

Verify that that JM = DJ

sparse_matrices_equal( J@M, D@J )
True

Visualize the difference JM - DJ, just to be thorough:

fig, axs = plt.subplots(1,3)
fig.set_figwidth(12)


axs[0].spy(D @ J,   precision=0, marker=None, markersize=1, color="orange", aspect='equal', origin='upper')
axs[1].spy(J @ M,       precision=0, marker=None, markersize=1, color="orange", aspect='equal', origin='upper')
axs[2].spy( (J@M) - (D@J),       precision=0, marker=None, markersize=1, color="orange", aspect='equal', origin='upper')
axs[0].set_title("DJ", y=-0.2)
axs[1].set_title("JM", y=-0.2)
axs[2].set_title("JM - DJ", y=-0.2)
plt.tight_layout()
DJ, JM, JM - DJ

# An R = DV decomposition

As noted above, Umatch decompositions can be used to obtain \(R=DV\) decompositions by setting \(V=J\) and \(R=DJ\).

Scipy has limited functionality for coefficients of type Fraction, so convert to float

J.data              =   J.data.astype(float)
D.data              =   D.data.astype(float)

#   plot sparsity patterns
fig, axs = plt.subplots(1,3)
fig.set_figwidth(12)


axs[0].spy(D @ J,   precision=0, marker=None, markersize=1, color="orange", aspect='equal', origin='upper')
axs[1].spy(D,       precision=0, marker=None, markersize=1, color="orange", aspect='equal', origin='upper')
axs[2].spy(J,       precision=0, marker=None, markersize=1, color="orange", aspect='equal', origin='upper')
axs[0].set_title("R", y=-0.2)
axs[1].set_title("D", y=-0.2)
axs[2].set_title("V", y=-0.2)
plt.tight_layout()
R, D, V

Verify that R: = DJ is in fact reducted

def bottom_nonzero_rows_are_unique(A: csr_matrix):
    """
    Returns True if, for all distinct nonzero columns i and j of A,
    the bottom (last) nonzero element of each column occurs in a different row.
    """
    # Find nonzero columns
    nonzero_cols = np.flatnonzero(A.getnnz(axis=0))
    # For each nonzero column, find the row index of the bottom nonzero element
    bottom_rows = []
    for col in nonzero_cols:
        col_data = A[:, col].tocoo()
        if col_data.row.size > 0:
            bottom_rows.append(col_data.row.max())
    # Check if all bottom rows are unique
    return len(bottom_rows) == len(set(bottom_rows))

# Example usage:
bottom_nonzero_rows_are_unique(D @J)
True

An RU = D decomposition

As noted above, Umatch decompositions can be used to obtain \(RU = D\) decompositions by setting \(U = J^{-1}\) and \(R = DJ\).

Jinv                =   decomposition.differential_comb_inverse_as_csr()

#   Scipy has limited functionality for coefficients of type Fraction, so convert to float
Jinv.data           =   Jinv.data.astype(float)
D.data              =   D.data.astype(float)

#   plot sparsity patterns
fig, axs = plt.subplots(1,3)
fig.set_figwidth(12)


axs[0].spy((D @ J) @ Jinv,   precision=0, marker=None, markersize=1, color="orange", aspect='equal', origin='upper')
axs[1].spy(Jinv,       precision=0, marker=None, markersize=1, color="orange", aspect='equal', origin='upper')
axs[2].spy(D @ J,       precision=0, marker=None, markersize=1, color="orange", aspect='equal', origin='upper')
axs[0].set_title("R", y=-0.2)
axs[1].set_title("U", y=-0.2)
axs[2].set_title("D", y=-0.2)
plt.tight_layout()
R, U, D

An \(R=DV\) decomposition of \(D^\perp\) (persistent cohomology)

This is equivalent to a matrix equation R = WD where W is invertible and upper triangular, and where R is reduced in the sense that the leading entry of every nonzero row occurs in a distinct column.

fig, axs = plt.subplots(1,3)
fig.set_figwidth(12)

axs[0].spy(Jinv @ D,   precision=0, marker=None, markersize=1, color="orange", aspect='equal', origin='upper')
axs[1].spy(Jinv,       precision=0, marker=None, markersize=1, color="orange", aspect='equal', origin='upper')
axs[2].spy(D,       precision=0, marker=None, markersize=1, color="orange", aspect='equal', origin='upper')
axs[0].set_title("R", y=-0.2)
axs[1].set_title("W", y=-0.2)
axs[2].set_title("D", y=-0.2)
plt.tight_layout()
R, W, D

Verify that \(R\) is reduced in the correct sense.

def leading_nonzero_cols_are_unique(A: csr_matrix):
    """
    Returns True if, for all distinct nonzero rows i and j of A,
    the leading (first) nonzero element of each row occurs in a different column.
    """
    # Find nonzero rows
    nonzero_rows = np.flatnonzero(A.getnnz(axis=1))
    # For each nonzero row, find the column index of the first nonzero element
    leading_cols = []
    for row in nonzero_rows:
        row_data = A[row, :].tocoo()
        if row_data.col.size > 0:
            leading_cols.append(row_data.col.min())
    # Check if all leading columns are unique
    return len(leading_cols) == len(set(leading_cols))

# Example usage:
leading_nonzero_cols_are_unique( Jinv @ D)
True

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

Gallery generated by Sphinx-Gallery