|
""" |
|
Laplacian of a compressed-sparse graph |
|
""" |
|
|
|
import numpy as np |
|
from scipy.sparse import issparse |
|
from scipy.sparse.linalg import LinearOperator |
|
from scipy.sparse._sputils import convert_pydata_sparse_to_scipy, is_pydata_spmatrix |
|
|
|
|
|
|
|
|
|
def laplacian( |
|
csgraph, |
|
normed=False, |
|
return_diag=False, |
|
use_out_degree=False, |
|
*, |
|
copy=True, |
|
form="array", |
|
dtype=None, |
|
symmetrized=False, |
|
): |
|
""" |
|
Return the Laplacian of a directed graph. |
|
|
|
Parameters |
|
---------- |
|
csgraph : array_like or sparse array or matrix, 2 dimensions |
|
compressed-sparse graph, with shape (N, N). |
|
normed : bool, optional |
|
If True, then compute symmetrically normalized Laplacian. |
|
Default: False. |
|
return_diag : bool, optional |
|
If True, then also return an array related to vertex degrees. |
|
Default: False. |
|
use_out_degree : bool, optional |
|
If True, then use out-degree instead of in-degree. |
|
This distinction matters only if the graph is asymmetric. |
|
Default: False. |
|
copy: bool, optional |
|
If False, then change `csgraph` in place if possible, |
|
avoiding doubling the memory use. |
|
Default: True, for backward compatibility. |
|
form: 'array', or 'function', or 'lo' |
|
Determines the format of the output Laplacian: |
|
|
|
* 'array' is a numpy array; |
|
* 'function' is a pointer to evaluating the Laplacian-vector |
|
or Laplacian-matrix product; |
|
* 'lo' results in the format of the `LinearOperator`. |
|
|
|
Choosing 'function' or 'lo' always avoids doubling |
|
the memory use, ignoring `copy` value. |
|
Default: 'array', for backward compatibility. |
|
dtype: None or one of numeric numpy dtypes, optional |
|
The dtype of the output. If ``dtype=None``, the dtype of the |
|
output matches the dtype of the input csgraph, except for |
|
the case ``normed=True`` and integer-like csgraph, where |
|
the output dtype is 'float' allowing accurate normalization, |
|
but dramatically increasing the memory use. |
|
Default: None, for backward compatibility. |
|
symmetrized: bool, optional |
|
If True, then the output Laplacian is symmetric/Hermitian. |
|
The symmetrization is done by ``csgraph + csgraph.T.conj`` |
|
without dividing by 2 to preserve integer dtypes if possible |
|
prior to the construction of the Laplacian. |
|
The symmetrization will increase the memory footprint of |
|
sparse matrices unless the sparsity pattern is symmetric or |
|
`form` is 'function' or 'lo'. |
|
Default: False, for backward compatibility. |
|
|
|
Returns |
|
------- |
|
lap : ndarray, or sparse array or matrix, or `LinearOperator` |
|
The N x N Laplacian of csgraph. It will be a NumPy array (dense) |
|
if the input was dense, or a sparse array otherwise, or |
|
the format of a function or `LinearOperator` if |
|
`form` equals 'function' or 'lo', respectively. |
|
diag : ndarray, optional |
|
The length-N main diagonal of the Laplacian matrix. |
|
For the normalized Laplacian, this is the array of square roots |
|
of vertex degrees or 1 if the degree is zero. |
|
|
|
Notes |
|
----- |
|
The Laplacian matrix of a graph is sometimes referred to as the |
|
"Kirchhoff matrix" or just the "Laplacian", and is useful in many |
|
parts of spectral graph theory. |
|
In particular, the eigen-decomposition of the Laplacian can give |
|
insight into many properties of the graph, e.g., |
|
is commonly used for spectral data embedding and clustering. |
|
|
|
The constructed Laplacian doubles the memory use if ``copy=True`` and |
|
``form="array"`` which is the default. |
|
Choosing ``copy=False`` has no effect unless ``form="array"`` |
|
or the matrix is sparse in the ``coo`` format, or dense array, except |
|
for the integer input with ``normed=True`` that forces the float output. |
|
|
|
Sparse input is reformatted into ``coo`` if ``form="array"``, |
|
which is the default. |
|
|
|
If the input adjacency matrix is not symmetric, the Laplacian is |
|
also non-symmetric unless ``symmetrized=True`` is used. |
|
|
|
Diagonal entries of the input adjacency matrix are ignored and |
|
replaced with zeros for the purpose of normalization where ``normed=True``. |
|
The normalization uses the inverse square roots of row-sums of the input |
|
adjacency matrix, and thus may fail if the row-sums contain |
|
negative or complex with a non-zero imaginary part values. |
|
|
|
The normalization is symmetric, making the normalized Laplacian also |
|
symmetric if the input csgraph was symmetric. |
|
|
|
References |
|
---------- |
|
.. [1] Laplacian matrix. https://en.wikipedia.org/wiki/Laplacian_matrix |
|
|
|
Examples |
|
-------- |
|
>>> import numpy as np |
|
>>> from scipy.sparse import csgraph |
|
|
|
Our first illustration is the symmetric graph |
|
|
|
>>> G = np.arange(4) * np.arange(4)[:, np.newaxis] |
|
>>> G |
|
array([[0, 0, 0, 0], |
|
[0, 1, 2, 3], |
|
[0, 2, 4, 6], |
|
[0, 3, 6, 9]]) |
|
|
|
and its symmetric Laplacian matrix |
|
|
|
>>> csgraph.laplacian(G) |
|
array([[ 0, 0, 0, 0], |
|
[ 0, 5, -2, -3], |
|
[ 0, -2, 8, -6], |
|
[ 0, -3, -6, 9]]) |
|
|
|
The non-symmetric graph |
|
|
|
>>> G = np.arange(9).reshape(3, 3) |
|
>>> G |
|
array([[0, 1, 2], |
|
[3, 4, 5], |
|
[6, 7, 8]]) |
|
|
|
has different row- and column sums, resulting in two varieties |
|
of the Laplacian matrix, using an in-degree, which is the default |
|
|
|
>>> L_in_degree = csgraph.laplacian(G) |
|
>>> L_in_degree |
|
array([[ 9, -1, -2], |
|
[-3, 8, -5], |
|
[-6, -7, 7]]) |
|
|
|
or alternatively an out-degree |
|
|
|
>>> L_out_degree = csgraph.laplacian(G, use_out_degree=True) |
|
>>> L_out_degree |
|
array([[ 3, -1, -2], |
|
[-3, 8, -5], |
|
[-6, -7, 13]]) |
|
|
|
Constructing a symmetric Laplacian matrix, one can add the two as |
|
|
|
>>> L_in_degree + L_out_degree.T |
|
array([[ 12, -4, -8], |
|
[ -4, 16, -12], |
|
[ -8, -12, 20]]) |
|
|
|
or use the ``symmetrized=True`` option |
|
|
|
>>> csgraph.laplacian(G, symmetrized=True) |
|
array([[ 12, -4, -8], |
|
[ -4, 16, -12], |
|
[ -8, -12, 20]]) |
|
|
|
that is equivalent to symmetrizing the original graph |
|
|
|
>>> csgraph.laplacian(G + G.T) |
|
array([[ 12, -4, -8], |
|
[ -4, 16, -12], |
|
[ -8, -12, 20]]) |
|
|
|
The goal of normalization is to make the non-zero diagonal entries |
|
of the Laplacian matrix to be all unit, also scaling off-diagonal |
|
entries correspondingly. The normalization can be done manually, e.g., |
|
|
|
>>> G = np.array([[0, 1, 1], [1, 0, 1], [1, 1, 0]]) |
|
>>> L, d = csgraph.laplacian(G, return_diag=True) |
|
>>> L |
|
array([[ 2, -1, -1], |
|
[-1, 2, -1], |
|
[-1, -1, 2]]) |
|
>>> d |
|
array([2, 2, 2]) |
|
>>> scaling = np.sqrt(d) |
|
>>> scaling |
|
array([1.41421356, 1.41421356, 1.41421356]) |
|
>>> (1/scaling)*L*(1/scaling) |
|
array([[ 1. , -0.5, -0.5], |
|
[-0.5, 1. , -0.5], |
|
[-0.5, -0.5, 1. ]]) |
|
|
|
Or using ``normed=True`` option |
|
|
|
>>> L, d = csgraph.laplacian(G, return_diag=True, normed=True) |
|
>>> L |
|
array([[ 1. , -0.5, -0.5], |
|
[-0.5, 1. , -0.5], |
|
[-0.5, -0.5, 1. ]]) |
|
|
|
which now instead of the diagonal returns the scaling coefficients |
|
|
|
>>> d |
|
array([1.41421356, 1.41421356, 1.41421356]) |
|
|
|
Zero scaling coefficients are substituted with 1s, where scaling |
|
has thus no effect, e.g., |
|
|
|
>>> G = np.array([[0, 0, 0], [0, 0, 1], [0, 1, 0]]) |
|
>>> G |
|
array([[0, 0, 0], |
|
[0, 0, 1], |
|
[0, 1, 0]]) |
|
>>> L, d = csgraph.laplacian(G, return_diag=True, normed=True) |
|
>>> L |
|
array([[ 0., -0., -0.], |
|
[-0., 1., -1.], |
|
[-0., -1., 1.]]) |
|
>>> d |
|
array([1., 1., 1.]) |
|
|
|
Only the symmetric normalization is implemented, resulting |
|
in a symmetric Laplacian matrix if and only if its graph is symmetric |
|
and has all non-negative degrees, like in the examples above. |
|
|
|
The output Laplacian matrix is by default a dense array or a sparse |
|
array or matrix inferring its class, shape, format, and dtype from |
|
the input graph matrix: |
|
|
|
>>> G = np.array([[0, 1, 1], [1, 0, 1], [1, 1, 0]]).astype(np.float32) |
|
>>> G |
|
array([[0., 1., 1.], |
|
[1., 0., 1.], |
|
[1., 1., 0.]], dtype=float32) |
|
>>> csgraph.laplacian(G) |
|
array([[ 2., -1., -1.], |
|
[-1., 2., -1.], |
|
[-1., -1., 2.]], dtype=float32) |
|
|
|
but can alternatively be generated matrix-free as a LinearOperator: |
|
|
|
>>> L = csgraph.laplacian(G, form="lo") |
|
>>> L |
|
<3x3 _CustomLinearOperator with dtype=float32> |
|
>>> L(np.eye(3)) |
|
array([[ 2., -1., -1.], |
|
[-1., 2., -1.], |
|
[-1., -1., 2.]]) |
|
|
|
or as a lambda-function: |
|
|
|
>>> L = csgraph.laplacian(G, form="function") |
|
>>> L |
|
<function _laplace.<locals>.<lambda> at 0x0000012AE6F5A598> |
|
>>> L(np.eye(3)) |
|
array([[ 2., -1., -1.], |
|
[-1., 2., -1.], |
|
[-1., -1., 2.]]) |
|
|
|
The Laplacian matrix is used for |
|
spectral data clustering and embedding |
|
as well as for spectral graph partitioning. |
|
Our final example illustrates the latter |
|
for a noisy directed linear graph. |
|
|
|
>>> from scipy.sparse import diags_array, random_array |
|
>>> from scipy.sparse.linalg import lobpcg |
|
|
|
Create a directed linear graph with ``N=35`` vertices |
|
using a sparse adjacency matrix ``G``: |
|
|
|
>>> N = 35 |
|
>>> G = diags_array(np.ones(N - 1), offsets=1, format="csr") |
|
|
|
Fix a random seed ``rng`` and add a random sparse noise to the graph ``G``: |
|
|
|
>>> rng = np.random.default_rng() |
|
>>> G += 1e-2 * random_array((N, N), density=0.1, rng=rng) |
|
|
|
Set initial approximations for eigenvectors: |
|
|
|
>>> X = rng.random((N, 2)) |
|
|
|
The constant vector of ones is always a trivial eigenvector |
|
of the non-normalized Laplacian to be filtered out: |
|
|
|
>>> Y = np.ones((N, 1)) |
|
|
|
Alternating (1) the sign of the graph weights allows determining |
|
labels for spectral max- and min- cuts in a single loop. |
|
Since the graph is undirected, the option ``symmetrized=True`` |
|
must be used in the construction of the Laplacian. |
|
The option ``normed=True`` cannot be used in (2) for the negative weights |
|
here as the symmetric normalization evaluates square roots. |
|
The option ``form="lo"`` in (2) is matrix-free, i.e., guarantees |
|
a fixed memory footprint and read-only access to the graph. |
|
Calling the eigenvalue solver ``lobpcg`` (3) computes the Fiedler vector |
|
that determines the labels as the signs of its components in (5). |
|
Since the sign in an eigenvector is not deterministic and can flip, |
|
we fix the sign of the first component to be always +1 in (4). |
|
|
|
>>> for cut in ["max", "min"]: |
|
... G = -G # 1. |
|
... L = csgraph.laplacian(G, symmetrized=True, form="lo") # 2. |
|
... _, eves = lobpcg(L, X, Y=Y, largest=False, tol=1e-2) # 3. |
|
... eves *= np.sign(eves[0, 0]) # 4. |
|
... print(cut + "-cut labels:\\n", 1 * (eves[:, 0]>0)) # 5. |
|
max-cut labels: |
|
[1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1] |
|
min-cut labels: |
|
[1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0] |
|
|
|
As anticipated for a (slightly noisy) linear graph, |
|
the max-cut strips all the edges of the graph coloring all |
|
odd vertices into one color and all even vertices into another one, |
|
while the balanced min-cut partitions the graph |
|
in the middle by deleting a single edge. |
|
Both determined partitions are optimal. |
|
""" |
|
is_pydata_sparse = is_pydata_spmatrix(csgraph) |
|
if is_pydata_sparse: |
|
pydata_sparse_cls = csgraph.__class__ |
|
csgraph = convert_pydata_sparse_to_scipy(csgraph) |
|
if csgraph.ndim != 2 or csgraph.shape[0] != csgraph.shape[1]: |
|
raise ValueError('csgraph must be a square matrix or array') |
|
|
|
if normed and ( |
|
np.issubdtype(csgraph.dtype, np.signedinteger) |
|
or np.issubdtype(csgraph.dtype, np.uint) |
|
): |
|
csgraph = csgraph.astype(np.float64) |
|
|
|
if form == "array": |
|
create_lap = ( |
|
_laplacian_sparse if issparse(csgraph) else _laplacian_dense |
|
) |
|
else: |
|
create_lap = ( |
|
_laplacian_sparse_flo |
|
if issparse(csgraph) |
|
else _laplacian_dense_flo |
|
) |
|
|
|
degree_axis = 1 if use_out_degree else 0 |
|
|
|
lap, d = create_lap( |
|
csgraph, |
|
normed=normed, |
|
axis=degree_axis, |
|
copy=copy, |
|
form=form, |
|
dtype=dtype, |
|
symmetrized=symmetrized, |
|
) |
|
if is_pydata_sparse: |
|
lap = pydata_sparse_cls.from_scipy_sparse(lap) |
|
if return_diag: |
|
return lap, d |
|
return lap |
|
|
|
|
|
def _setdiag_dense(m, d): |
|
step = len(d) + 1 |
|
m.flat[::step] = d |
|
|
|
|
|
def _laplace(m, d): |
|
return lambda v: v * d[:, np.newaxis] - m @ v |
|
|
|
|
|
def _laplace_normed(m, d, nd): |
|
laplace = _laplace(m, d) |
|
return lambda v: nd[:, np.newaxis] * laplace(v * nd[:, np.newaxis]) |
|
|
|
|
|
def _laplace_sym(m, d): |
|
return ( |
|
lambda v: v * d[:, np.newaxis] |
|
- m @ v |
|
- np.transpose(np.conjugate(np.transpose(np.conjugate(v)) @ m)) |
|
) |
|
|
|
|
|
def _laplace_normed_sym(m, d, nd): |
|
laplace_sym = _laplace_sym(m, d) |
|
return lambda v: nd[:, np.newaxis] * laplace_sym(v * nd[:, np.newaxis]) |
|
|
|
|
|
def _linearoperator(mv, shape, dtype): |
|
return LinearOperator(matvec=mv, matmat=mv, shape=shape, dtype=dtype) |
|
|
|
|
|
def _laplacian_sparse_flo(graph, normed, axis, copy, form, dtype, symmetrized): |
|
|
|
del copy |
|
|
|
if dtype is None: |
|
dtype = graph.dtype |
|
|
|
graph_sum = np.asarray(graph.sum(axis=axis)).ravel() |
|
graph_diagonal = graph.diagonal() |
|
diag = graph_sum - graph_diagonal |
|
if symmetrized: |
|
graph_sum += np.asarray(graph.sum(axis=1 - axis)).ravel() |
|
diag = graph_sum - graph_diagonal - graph_diagonal |
|
|
|
if normed: |
|
isolated_node_mask = diag == 0 |
|
w = np.where(isolated_node_mask, 1, np.sqrt(diag)) |
|
if symmetrized: |
|
md = _laplace_normed_sym(graph, graph_sum, 1.0 / w) |
|
else: |
|
md = _laplace_normed(graph, graph_sum, 1.0 / w) |
|
if form == "function": |
|
return md, w.astype(dtype, copy=False) |
|
elif form == "lo": |
|
m = _linearoperator(md, shape=graph.shape, dtype=dtype) |
|
return m, w.astype(dtype, copy=False) |
|
else: |
|
raise ValueError(f"Invalid form: {form!r}") |
|
else: |
|
if symmetrized: |
|
md = _laplace_sym(graph, graph_sum) |
|
else: |
|
md = _laplace(graph, graph_sum) |
|
if form == "function": |
|
return md, diag.astype(dtype, copy=False) |
|
elif form == "lo": |
|
m = _linearoperator(md, shape=graph.shape, dtype=dtype) |
|
return m, diag.astype(dtype, copy=False) |
|
else: |
|
raise ValueError(f"Invalid form: {form!r}") |
|
|
|
|
|
def _laplacian_sparse(graph, normed, axis, copy, form, dtype, symmetrized): |
|
|
|
del form |
|
|
|
if dtype is None: |
|
dtype = graph.dtype |
|
|
|
needs_copy = False |
|
if graph.format in ('lil', 'dok'): |
|
m = graph.tocoo() |
|
else: |
|
m = graph |
|
if copy: |
|
needs_copy = True |
|
|
|
if symmetrized: |
|
m += m.T.conj() |
|
|
|
w = np.asarray(m.sum(axis=axis)).ravel() - m.diagonal() |
|
if normed: |
|
m = m.tocoo(copy=needs_copy) |
|
isolated_node_mask = (w == 0) |
|
w = np.where(isolated_node_mask, 1, np.sqrt(w)) |
|
m.data /= w[m.row] |
|
m.data /= w[m.col] |
|
m.data *= -1 |
|
m.setdiag(1 - isolated_node_mask) |
|
else: |
|
if m.format == 'dia': |
|
m = m.copy() |
|
else: |
|
m = m.tocoo(copy=needs_copy) |
|
m.data *= -1 |
|
m.setdiag(w) |
|
|
|
return m.astype(dtype, copy=False), w.astype(dtype) |
|
|
|
|
|
def _laplacian_dense_flo(graph, normed, axis, copy, form, dtype, symmetrized): |
|
|
|
if copy: |
|
m = np.array(graph) |
|
else: |
|
m = np.asarray(graph) |
|
|
|
if dtype is None: |
|
dtype = m.dtype |
|
|
|
graph_sum = m.sum(axis=axis) |
|
graph_diagonal = m.diagonal() |
|
diag = graph_sum - graph_diagonal |
|
if symmetrized: |
|
graph_sum += m.sum(axis=1 - axis) |
|
diag = graph_sum - graph_diagonal - graph_diagonal |
|
|
|
if normed: |
|
isolated_node_mask = diag == 0 |
|
w = np.where(isolated_node_mask, 1, np.sqrt(diag)) |
|
if symmetrized: |
|
md = _laplace_normed_sym(m, graph_sum, 1.0 / w) |
|
else: |
|
md = _laplace_normed(m, graph_sum, 1.0 / w) |
|
if form == "function": |
|
return md, w.astype(dtype, copy=False) |
|
elif form == "lo": |
|
m = _linearoperator(md, shape=graph.shape, dtype=dtype) |
|
return m, w.astype(dtype, copy=False) |
|
else: |
|
raise ValueError(f"Invalid form: {form!r}") |
|
else: |
|
if symmetrized: |
|
md = _laplace_sym(m, graph_sum) |
|
else: |
|
md = _laplace(m, graph_sum) |
|
if form == "function": |
|
return md, diag.astype(dtype, copy=False) |
|
elif form == "lo": |
|
m = _linearoperator(md, shape=graph.shape, dtype=dtype) |
|
return m, diag.astype(dtype, copy=False) |
|
else: |
|
raise ValueError(f"Invalid form: {form!r}") |
|
|
|
|
|
def _laplacian_dense(graph, normed, axis, copy, form, dtype, symmetrized): |
|
|
|
if form != "array": |
|
raise ValueError(f'{form!r} must be "array"') |
|
|
|
if dtype is None: |
|
dtype = graph.dtype |
|
|
|
if copy: |
|
m = np.array(graph) |
|
else: |
|
m = np.asarray(graph) |
|
|
|
if dtype is None: |
|
dtype = m.dtype |
|
|
|
if symmetrized: |
|
m += m.T.conj() |
|
np.fill_diagonal(m, 0) |
|
w = m.sum(axis=axis) |
|
if normed: |
|
isolated_node_mask = (w == 0) |
|
w = np.where(isolated_node_mask, 1, np.sqrt(w)) |
|
m /= w |
|
m /= w[:, np.newaxis] |
|
m *= -1 |
|
_setdiag_dense(m, 1 - isolated_node_mask) |
|
else: |
|
m *= -1 |
|
_setdiag_dense(m, w) |
|
|
|
return m.astype(dtype, copy=False), w.astype(dtype, copy=False) |
|
|