Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Speed and scalability improvements for graph multiresolution #3

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 10 additions & 0 deletions doc/reference/utils.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,13 @@ Distanz
Resistance distance
-------------------
.. autofunction:: pygsp.utils.resistance_distance

.. autofunction:: pygsp.utils.approx_resistance_distance

SDD optimized sparse linear system solver
-----------------------------------------
.. autofunction:: pygsp.utils.splu_inv_dot

Sparse matrix block extraction
------------------------------
.. autofunction:: pygsp.utils.extract_submatrix
26 changes: 26 additions & 0 deletions pygsp/graphs/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,32 @@ def create_laplacian(self, lap_type='combinatorial'):

self.L = L

def create_incidence_matrix(self):
r"""
Compute a new incidence matrix B and associated edge weight matrix Wb.

The combinatorial graph laplacian can be recovered with L = B.T Wb B
For convenience, the heads and tails of each edge are saved in two
additional attributes start_nodes and end_nodes.
"""
if not hasattr(self, 'directed'):
self.is_directed()

if self.directed or not self.connected:
raise NotImplementedError('Focusing on connected non directed graphs first.')

start_nodes, end_nodes, weights = sparse.find(sparse.tril(self.W))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know if this is faster/slower/same as what's done in adj2vec(), lines 23-24 in pygsp/data_handling.py? Indeed, I rather prefer your design, than the use of adj2vec we're making now for computing graph gradients. I might make the necessary adaptions to replace it by this call of your create_incidence_matrix soon.


data = np.concatenate([np.ones(self.Ne/2), -np.ones(self.Ne/2)])
row = np.concatenate([np.arange(self.Ne/2), np.arange(self.Ne/2)])
col = np.concatenate([start_nodes, end_nodes])

self.B = sparse.coo_matrix((data, (row, col)),
shape=(self.Ne/2, self.N) ).tocsc()
self.Wb = sparse.diags(weights,0)
self.start_nodes = start_nodes
self.end_nodes = end_nodes

def estimate_lmax(self, force_recompute=False):
r"""
Estimate the maximal eigenvalue.
Expand Down
94 changes: 53 additions & 41 deletions pygsp/operators/reduction.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
r"""This module contains functionalities for the reduction of graphs' vertex set while keeping the graph structure."""

from ..utils import resistance_distance, build_logger
from ..utils import resistance_distance, build_logger, extract_submatrix, splu_inv_dot, approx_resistance_distance
from ..graphs import Graph
from ..filters import Filter

Expand All @@ -12,17 +12,26 @@
logger = build_logger(__name__)


def graph_sparsify(M, epsilon, maxiter=10):
def graph_sparsify(M, epsilon, maxiter=10, fast=True):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would set fast=False by default, so that the previous default behavior of this function remains unchanged. What do you think?

r"""
Sparsify a graph using Spielman-Srivastava algorithm.

Parameters
----------
M : Graph or sparse matrix
Graph structure or a Laplacian matrix
epsilon : int

epsilon : float
Sparsification parameter

maxiter : int (optional)
Number of iterations in successive attempts at reducing the sparsification
parameter to preserve connectivity. (default: 10)

fast : bool
Whether to use the fast resistance distance from :cite:`spielman2011graph`
or exact value. (default: True)

Returns
-------
Mnew : Graph or sparse matrix
Expand All @@ -32,6 +41,11 @@ def graph_sparsify(M, epsilon, maxiter=10):
-----
Epsilon should be between 1/sqrt(N) and 1

The resistance distances computed by the `fast` option are approximate but
that approximation is included in the graph sparsification bounds of the
Spielman-Srivastava algorithm. Without this option, distances are computed
by blunt matrix inversion which does not scale for large graphs.

Examples
--------
>>> from pygsp import graphs, operators
Expand All @@ -49,35 +63,26 @@ def graph_sparsify(M, epsilon, maxiter=10):
if isinstance(M, Graph):
if not M.lap_type == 'combinatorial':
raise NotImplementedError
L = M.L
g = M
g.create_incidence_matrix()
else:
L = M
g = Graph(W=sparse.diags(M.diagonal()) - M, lap_type='combinatorial')
g.create_incidence_matrix()

N = np.shape(L)[0]
N = g.N

if not 1./np.sqrt(N) <= epsilon < 1:
raise ValueError('GRAPH_SPARSIFY: Epsilon out of required range')

# Not sparse
resistance_distances = resistance_distance(L).toarray()
# Get the Weight matrix
if isinstance(M, Graph):
W = M.W
if fast:
Re = approx_resistance_distance(g, epsilon)
else:
W = np.diag(L.diagonal()) - L.toarray()
W[W < 1e-10] = 0

W = sparse.coo_matrix(W)
W.data[W.data < 1e-10] = 0
W = W.tocsc()
W.eliminate_zeros()


start_nodes, end_nodes, weights = sparse.find(sparse.tril(W))
Re = resistance_distance(g.L).toarray()
Re = Re[g.start_nodes, g.end_nodes]

# Calculate the new weights.
weights = np.maximum(0, weights)
Re = np.maximum(0, resistance_distances[start_nodes, end_nodes])
weights = np.maximum(0, g.Wb.diagonal())
Re = np.maximum(0, Re)
Pe = weights * Re
Pe = Pe / np.sum(Pe)

Expand All @@ -97,7 +102,7 @@ def graph_sparsify(M, epsilon, maxiter=10):
counts[spin_counts[:, 0]] = spin_counts[:, 1]
new_weights = counts * per_spin_weights

sparserW = sparse.csc_matrix((new_weights, (start_nodes, end_nodes)),
sparserW = sparse.csc_matrix((new_weights, (g.start_nodes, g.end_nodes)),
shape=(N, N))
sparserW = sparserW + sparserW.T
sparserL = sparse.diags(sparserW.diagonal(), 0) - sparserW
Expand Down Expand Up @@ -279,7 +284,7 @@ def graph_multiresolution(G, levels, **kwargs):
return Gs


def kron_reduction(G, ind):
def kron_reduction(G, ind, threshold=np.spacing(1)):
r"""
Compute the kron reduction.

Expand All @@ -295,12 +300,22 @@ def kron_reduction(G, ind):
Graph structure or weight matrix
ind : list
indices of the nodes to keep
threshold: float
Threshold applied to the reduced Laplacian matrix to remove numerical
noise. (default: marchine precision)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo: machine


Returns
-------
Gnew : Graph or sparse matrix
New graph structure or weight matrix

Notes
-----
For large graphs, with default thresholding value, the kron reduction can
lead to an extremely large number of edges, most of which have very small
weight. In this situation, a larger thresholding can remove most of these
unnecessary edges, an approximation that also makes subsequent spasrsification
much faster.

References
----------
Expand All @@ -327,27 +342,24 @@ def kron_reduction(G, ind):
N = np.shape(L)[0]
ind_comp = np.setdiff1d(np.arange(N, dtype=int), ind)

L_red = L[np.ix_(ind, ind)]
L_in_out = L[np.ix_(ind, ind_comp)]
L_out_in = L[np.ix_(ind_comp, ind)].tocsc()
L_comp = L[np.ix_(ind_comp, ind_comp)].tocsc()
L_red = extract_submatrix(L,ind, ind)
L_in_out = extract_submatrix(L, ind, ind_comp)
L_out_in = L_in_out.transpose().tocsc()
L_comp = extract_submatrix(L,ind_comp, ind_comp).tocsc()

Lnew = L_red - L_in_out.dot(spsolve(L_comp, L_out_in))
Lnew = L_red - L_in_out.dot(splu_inv_dot(L_comp, L_out_in))

# Make the laplacian symmetric if it is almost symmetric!
if np.abs(Lnew - Lnew.T).sum() < np.spacing(1) * np.abs(Lnew).sum():
Lnew = (Lnew + Lnew.T) / 2.
# Threshold excedingly small values for stability
Lnew = Lnew.tocoo()
Lnew.data[abs(Lnew.data) < threshold] = 0
Lnew = Lnew.tocsc()
Lnew.eliminate_zeros()

# Enforces symmetric Laplacian
Lnew = (Lnew + Lnew.T) / 2.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we always want the Laplacian to be symmetric here? In the previous implementation, this line was called only under the conditional statement.


if isinstance(G, Graph):
# Suppress the diagonal ? This is a good question?
Wnew = sparse.diags(Lnew.diagonal(), 0) - Lnew
Snew = Lnew.diagonal() - np.ravel(Wnew.sum(0))
if np.linalg.norm(Snew, 2) >= np.spacing(1000):
Wnew = Wnew + sparse.diags(Snew, 0)

# Removing diagonal for stability
Wnew = Wnew - Wnew.diagonal()

coords = G.coords[ind, :] if len(G.coords.shape) else np.ndarray(None)
Gnew = Graph(W=Wnew, coords=coords, lap_type=G.lap_type,
plotting=G.plotting, gtype='Kron reduction')
Expand Down
140 changes: 140 additions & 0 deletions pygsp/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,143 @@ def resistance_distance(M): # 1 call dans operators.reduction
- pseudo - pseudo.T

return rd

def approx_resistance_distance(g, epsilon):
r"""
Compute the resistance distances of each edge of a graph using the
Spielman-Srivastava algorithm.

Parameters
----------
g : Graph
Graph structure

epsilon: float
Sparsification parameter

Returns
-------
rd : ndarray
distance for every edge in the graph

Examples
--------
>>>
>>>
>>>

Notes
-----
This implementation avoids the blunt matrix inversion of the exact distance
distance and can scale to very large graphs. The approximation error is
included in the budget of Spielman-Srivastava sparsification algorithm.

References
----------
:cite:`klein1993resistance` :cite:`spielman2011graph`

"""
g.create_incidence_matrix()
n = g.N
k = 24 * np.log( n / epsilon)
Q = ((np.random.rand(int(k),g.Wb.shape[0]) > 0.5)*2. -1)/np.sqrt(k)
Y = sparse.csc_matrix(Q).dot(np.sqrt(g.Wb).dot(g.B))

r = splu_inv_dot(g.L, Y.T)

return ((r[g.start_nodes] - r[g.end_nodes]).toarray()**2).sum(axis=1)

def extract_submatrix(M, ind_rows, ind_cols):
r"""
Extract a bloc of specific rows and columns from a sparse matrix.

Parameters
----------

M : sparse matrix
Input matrix

ind_rows: ndarray
Indices of rows to extract

ind_cols: ndarray
Indices of columns to extract

Returns
-------

sub_M: sparse matrix
Submatrix obtained from M keeping only the requested rows and columns

Examples
--------
>>> import scipy.sparse as sparse
>>> from pygsp import utils
>>> # Extracting first diagonal block from a sparse matrix
>>> M = sparse.csc_matrix((16, 16))
>>> ind_row = range(8); ind_col = range(8)
>>> block = utils.extract_submatrix(M, ind_row, ind_col)
>>> block.shape
(8, 8)

"""
M = M.tocoo()

# Finding elements of the sub-matrix
m = np.in1d(M.row, ind_rows) & np.in1d(M.col, ind_cols)
n_elem = m.sum()

# Finding new rows and column indices
# The concatenation with ind and ind_comp is there to account for the fact that some rows
# or columns may not have elements in them, which foils this np.unique trick
_, row = np.unique(np.concatenate([M.row[m], ind_rows]), return_inverse=True)
_, col = np.unique(np.concatenate([M.col[m], ind_cols]), return_inverse=True)

return sparse.coo_matrix((M.data[m], (row[:n_elem],col[:n_elem])),
shape=(len(ind_rows),len(ind_cols)),copy=True)


def splu_inv_dot(A, B, threshold=np.spacing(1)):
"""
Compute A^{-1}B for sparse matrix A assuming A is Symmetric Diagonally
Dominant (SDD).

Parameters
----------
A : sparse matrix
Input SDD matrix to invert, in CSC or CSR form.

B : sparse matrix
Matrix or vector of the right hand side

threshold: float, optional
Threshold to apply to result as to remove numerical noise before
conversion to sparse format. (default: machine precision)

Returns
-------
res: sparse matrix
Result of A^{-1}B

Notes
-----
This inversion by sparse linear system solving is optimized for SDD matrices
such as Graph Laplacians. Note that B is converted to a dense matrix before
being sent to splu, which is more computationally efficient but can lead to
very large memory usage if B is large.
"""
# Compute the LU decomposition of A
lu = sparse.linalg.splu(A,
diag_pivot_thresh=A.diagonal().min()*0.5,
permc_spec='MMD_AT_PLUS_A',
options={'SymmetricMode':True})

res = lu.solve(B.toarray())

# Threshold the result to remove numerical noise
res[abs(res) < threshold] = 0

# Convert to sparse matrix
res = sparse.csc_matrix(res)

return res