Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/scipy/sparse/csgraph/_laplacian.py: 10%
146 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-12 06:31 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-12 06:31 +0000
1"""
2Laplacian of a compressed-sparse graph
3"""
5import numpy as np
6from scipy.sparse import isspmatrix
7from scipy.sparse.linalg import LinearOperator
10###############################################################################
11# Graph laplacian
12def laplacian(
13 csgraph,
14 normed=False,
15 return_diag=False,
16 use_out_degree=False,
17 *,
18 copy=True,
19 form="array",
20 dtype=None,
21 symmetrized=False,
22):
23 """
24 Return the Laplacian of a directed graph.
26 Parameters
27 ----------
28 csgraph : array_like or sparse matrix, 2 dimensions
29 compressed-sparse graph, with shape (N, N).
30 normed : bool, optional
31 If True, then compute symmetrically normalized Laplacian.
32 Default: False.
33 return_diag : bool, optional
34 If True, then also return an array related to vertex degrees.
35 Default: False.
36 use_out_degree : bool, optional
37 If True, then use out-degree instead of in-degree.
38 This distinction matters only if the graph is asymmetric.
39 Default: False.
40 copy: bool, optional
41 If False, then change `csgraph` in place if possible,
42 avoiding doubling the memory use.
43 Default: True, for backward compatibility.
44 form: 'array', or 'function', or 'lo'
45 Determines the format of the output Laplacian:
47 * 'array' is a numpy array;
48 * 'function' is a pointer to evaluating the Laplacian-vector
49 or Laplacian-matrix product;
50 * 'lo' results in the format of the `LinearOperator`.
52 Choosing 'function' or 'lo' always avoids doubling
53 the memory use, ignoring `copy` value.
54 Default: 'array', for backward compatibility.
55 dtype: None or one of numeric numpy dtypes, optional
56 The dtype of the output. If ``dtype=None``, the dtype of the
57 output matches the dtype of the input csgraph, except for
58 the case ``normed=True`` and integer-like csgraph, where
59 the output dtype is 'float' allowing accurate normalization,
60 but dramatically increasing the memory use.
61 Default: None, for backward compatibility.
62 symmetrized: bool, optional
63 If True, then the output Laplacian is symmetric/Hermitian.
64 The symmetrization is done by ``csgraph + csgraph.T.conj``
65 without dividing by 2 to preserve integer dtypes if possible
66 prior to the construction of the Laplacian.
67 The symmetrization will increase the memory footprint of
68 sparse matrices unless the sparsity pattern is symmetric or
69 `form` is 'function' or 'lo'.
70 Default: False, for backward compatibility.
72 Returns
73 -------
74 lap : ndarray, or sparse matrix, or `LinearOperator`
75 The N x N Laplacian of csgraph. It will be a NumPy array (dense)
76 if the input was dense, or a sparse matrix otherwise, or
77 the format of a function or `LinearOperator` if
78 `form` equals 'function' or 'lo', respectively.
79 diag : ndarray, optional
80 The length-N main diagonal of the Laplacian matrix.
81 For the normalized Laplacian, this is the array of square roots
82 of vertex degrees or 1 if the degree is zero.
84 Notes
85 -----
86 The Laplacian matrix of a graph is sometimes referred to as the
87 "Kirchhoff matrix" or just the "Laplacian", and is useful in many
88 parts of spectral graph theory.
89 In particular, the eigen-decomposition of the Laplacian can give
90 insight into many properties of the graph, e.g.,
91 is commonly used for spectral data embedding and clustering.
93 The constructed Laplacian doubles the memory use if ``copy=True`` and
94 ``form="array"`` which is the default.
95 Choosing ``copy=False`` has no effect unless ``form="array"``
96 or the matrix is sparse in the ``coo`` format, or dense array, except
97 for the integer input with ``normed=True`` that forces the float output.
99 Sparse input is reformatted into ``coo`` if ``form="array"``,
100 which is the default.
102 If the input adjacency matrix is not symmetic, the Laplacian is
103 also non-symmetric unless ``symmetrized=True`` is used.
105 Diagonal entries of the input adjacency matrix are ignored and
106 replaced with zeros for the purpose of normalization where ``normed=True``.
107 The normalization uses the inverse square roots of row-sums of the input
108 adjacency matrix, and thus may fail if the row-sums contain
109 negative or complex with a non-zero imaginary part values.
111 The normalization is symmetric, making the normalized Laplacian also
112 symmetric if the input csgraph was symmetric.
114 References
115 ----------
116 .. [1] Laplacian matrix. https://en.wikipedia.org/wiki/Laplacian_matrix
118 Examples
119 --------
120 >>> import numpy as np
121 >>> from scipy.sparse import csgraph
123 Our first illustration is the symmetric graph
125 >>> G = np.arange(4) * np.arange(4)[:, np.newaxis]
126 >>> G
127 array([[0, 0, 0, 0],
128 [0, 1, 2, 3],
129 [0, 2, 4, 6],
130 [0, 3, 6, 9]])
132 and its symmetric Laplacian matrix
134 >>> csgraph.laplacian(G)
135 array([[ 0, 0, 0, 0],
136 [ 0, 5, -2, -3],
137 [ 0, -2, 8, -6],
138 [ 0, -3, -6, 9]])
140 The non-symmetric graph
142 >>> G = np.arange(9).reshape(3, 3)
143 >>> G
144 array([[0, 1, 2],
145 [3, 4, 5],
146 [6, 7, 8]])
148 has different row- and column sums, resulting in two varieties
149 of the Laplacian matrix, using an in-degree, which is the default
151 >>> L_in_degree = csgraph.laplacian(G)
152 >>> L_in_degree
153 array([[ 9, -1, -2],
154 [-3, 8, -5],
155 [-6, -7, 7]])
157 or alternatively an out-degree
159 >>> L_out_degree = csgraph.laplacian(G, use_out_degree=True)
160 >>> L_out_degree
161 array([[ 3, -1, -2],
162 [-3, 8, -5],
163 [-6, -7, 13]])
165 Constructing a symmetric Laplacian matrix, one can add the two as
167 >>> L_in_degree + L_out_degree.T
168 array([[ 12, -4, -8],
169 [ -4, 16, -12],
170 [ -8, -12, 20]])
172 or use the ``symmetrized=True`` option
174 >>> csgraph.laplacian(G, symmetrized=True)
175 array([[ 12, -4, -8],
176 [ -4, 16, -12],
177 [ -8, -12, 20]])
179 that is equivalent to symmetrizing the original graph
181 >>> csgraph.laplacian(G + G.T)
182 array([[ 12, -4, -8],
183 [ -4, 16, -12],
184 [ -8, -12, 20]])
186 The goal of normalization is to make the non-zero diagonal entries
187 of the Laplacian matrix to be all unit, also scaling off-diagonal
188 entries correspondingly. The normalization can be done manually, e.g.,
190 >>> G = np.array([[0, 1, 1], [1, 0, 1], [1, 1, 0]])
191 >>> L, d = csgraph.laplacian(G, return_diag=True)
192 >>> L
193 array([[ 2, -1, -1],
194 [-1, 2, -1],
195 [-1, -1, 2]])
196 >>> d
197 array([2, 2, 2])
198 >>> scaling = np.sqrt(d)
199 >>> scaling
200 array([1.41421356, 1.41421356, 1.41421356])
201 >>> (1/scaling)*L*(1/scaling)
202 array([[ 1. , -0.5, -0.5],
203 [-0.5, 1. , -0.5],
204 [-0.5, -0.5, 1. ]])
206 Or using ``normed=True`` option
208 >>> L, d = csgraph.laplacian(G, return_diag=True, normed=True)
209 >>> L
210 array([[ 1. , -0.5, -0.5],
211 [-0.5, 1. , -0.5],
212 [-0.5, -0.5, 1. ]])
214 which now instead of the diagonal returns the scaling coefficients
216 >>> d
217 array([1.41421356, 1.41421356, 1.41421356])
219 Zero scaling coefficients are substituted with 1s, where scaling
220 has thus no effect, e.g.,
222 >>> G = np.array([[0, 0, 0], [0, 0, 1], [0, 1, 0]])
223 >>> G
224 array([[0, 0, 0],
225 [0, 0, 1],
226 [0, 1, 0]])
227 >>> L, d = csgraph.laplacian(G, return_diag=True, normed=True)
228 >>> L
229 array([[ 0., -0., -0.],
230 [-0., 1., -1.],
231 [-0., -1., 1.]])
232 >>> d
233 array([1., 1., 1.])
235 Only the symmetric normalization is implemented, resulting
236 in a symmetric Laplacian matrix if and only if its graph is symmetric
237 and has all non-negative degrees, like in the examples above.
239 The output Laplacian matrix is by default a dense array or a sparse matrix
240 inferring its shape, format, and dtype from the input graph matrix:
242 >>> G = np.array([[0, 1, 1], [1, 0, 1], [1, 1, 0]]).astype(np.float32)
243 >>> G
244 array([[0., 1., 1.],
245 [1., 0., 1.],
246 [1., 1., 0.]], dtype=float32)
247 >>> csgraph.laplacian(G)
248 array([[ 2., -1., -1.],
249 [-1., 2., -1.],
250 [-1., -1., 2.]], dtype=float32)
252 but can alternatively be generated matrix-free as a LinearOperator:
254 >>> L = csgraph.laplacian(G, form="lo")
255 >>> L
256 <3x3 _CustomLinearOperator with dtype=float32>
257 >>> L(np.eye(3))
258 array([[ 2., -1., -1.],
259 [-1., 2., -1.],
260 [-1., -1., 2.]])
262 or as a lambda-function:
264 >>> L = csgraph.laplacian(G, form="function")
265 >>> L
266 <function _laplace.<locals>.<lambda> at 0x0000012AE6F5A598>
267 >>> L(np.eye(3))
268 array([[ 2., -1., -1.],
269 [-1., 2., -1.],
270 [-1., -1., 2.]])
272 The Laplacian matrix is used for
273 spectral data clustering and embedding
274 as well as for spectral graph partitioning.
275 Our final example illustrates the latter
276 for a noisy directed linear graph.
278 >>> from scipy.sparse import diags, random
279 >>> from scipy.sparse.linalg import lobpcg
281 Create a directed linear graph with ``N=35`` vertices
282 using a sparse adjacency matrix ``G``:
284 >>> N = 35
285 >>> G = diags(np.ones(N-1), 1, format="csr")
287 Fix a random seed ``rng`` and add a random sparse noise to the graph ``G``:
289 >>> rng = np.random.default_rng()
290 >>> G += 1e-2 * random(N, N, density=0.1, random_state=rng)
292 Set initial approximations for eigenvectors:
294 >>> X = rng.random((N, 2))
296 The constant vector of ones is always a trivial eigenvector
297 of the non-normalized Laplacian to be filtered out:
299 >>> Y = np.ones((N, 1))
301 Alternating (1) the sign of the graph weights allows determining
302 labels for spectral max- and min- cuts in a single loop.
303 Since the graph is undirected, the option ``symmetrized=True``
304 must be used in the construction of the Laplacian.
305 The option ``normed=True`` cannot be used in (2) for the negative weights
306 here as the symmetric normalization evaluates square roots.
307 The option ``form="lo"`` in (2) is matrix-free, i.e., guarantees
308 a fixed memory footprint and read-only access to the graph.
309 Calling the eigenvalue solver ``lobpcg`` (3) computes the Fiedler vector
310 that determines the labels as the signs of its components in (5).
311 Since the sign in an eigenvector is not deterministic and can flip,
312 we fix the sign of the first component to be always +1 in (4).
314 >>> for cut in ["max", "min"]:
315 ... G = -G # 1.
316 ... L = csgraph.laplacian(G, symmetrized=True, form="lo") # 2.
317 ... _, eves = lobpcg(L, X, Y=Y, largest=False, tol=1e-3) # 3.
318 ... eves *= np.sign(eves[0, 0]) # 4.
319 ... print(cut + "-cut labels:\\n", 1 * (eves[:, 0]>0)) # 5.
320 max-cut labels:
321 [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]
322 min-cut labels:
323 [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]
325 As anticipated for a (slightly noisy) linear graph,
326 the max-cut strips all the edges of the graph coloring all
327 odd vertices into one color and all even vertices into another one,
328 while the balanced min-cut partitions the graph
329 in the middle by deleting a single edge.
330 Both determined partitions are optimal.
331 """
332 if csgraph.ndim != 2 or csgraph.shape[0] != csgraph.shape[1]:
333 raise ValueError('csgraph must be a square matrix or array')
335 if normed and (
336 np.issubdtype(csgraph.dtype, np.signedinteger)
337 or np.issubdtype(csgraph.dtype, np.uint)
338 ):
339 csgraph = csgraph.astype(np.float64)
341 if form == "array":
342 create_lap = (
343 _laplacian_sparse if isspmatrix(csgraph) else _laplacian_dense
344 )
345 else:
346 create_lap = (
347 _laplacian_sparse_flo
348 if isspmatrix(csgraph)
349 else _laplacian_dense_flo
350 )
352 degree_axis = 1 if use_out_degree else 0
354 lap, d = create_lap(
355 csgraph,
356 normed=normed,
357 axis=degree_axis,
358 copy=copy,
359 form=form,
360 dtype=dtype,
361 symmetrized=symmetrized,
362 )
363 if return_diag:
364 return lap, d
365 return lap
368def _setdiag_dense(m, d):
369 step = len(d) + 1
370 m.flat[::step] = d
373def _laplace(m, d):
374 return lambda v: v * d[:, np.newaxis] - m @ v
377def _laplace_normed(m, d, nd):
378 laplace = _laplace(m, d)
379 return lambda v: nd[:, np.newaxis] * laplace(v * nd[:, np.newaxis])
382def _laplace_sym(m, d):
383 return (
384 lambda v: v * d[:, np.newaxis]
385 - m @ v
386 - np.transpose(np.conjugate(np.transpose(np.conjugate(v)) @ m))
387 )
390def _laplace_normed_sym(m, d, nd):
391 laplace_sym = _laplace_sym(m, d)
392 return lambda v: nd[:, np.newaxis] * laplace_sym(v * nd[:, np.newaxis])
395def _linearoperator(mv, shape, dtype):
396 return LinearOperator(matvec=mv, matmat=mv, shape=shape, dtype=dtype)
399def _laplacian_sparse_flo(graph, normed, axis, copy, form, dtype, symmetrized):
400 # The keyword argument `copy` is unused and has no effect here.
401 del copy
403 if dtype is None:
404 dtype = graph.dtype
406 graph_sum = graph.sum(axis=axis).getA1()
407 graph_diagonal = graph.diagonal()
408 diag = graph_sum - graph_diagonal
409 if symmetrized:
410 graph_sum += graph.sum(axis=1 - axis).getA1()
411 diag = graph_sum - graph_diagonal - graph_diagonal
413 if normed:
414 isolated_node_mask = diag == 0
415 w = np.where(isolated_node_mask, 1, np.sqrt(diag))
416 if symmetrized:
417 md = _laplace_normed_sym(graph, graph_sum, 1.0 / w)
418 else:
419 md = _laplace_normed(graph, graph_sum, 1.0 / w)
420 if form == "function":
421 return md, w.astype(dtype, copy=False)
422 elif form == "lo":
423 m = _linearoperator(md, shape=graph.shape, dtype=dtype)
424 return m, w.astype(dtype, copy=False)
425 else:
426 raise ValueError(f"Invalid form: {form!r}")
427 else:
428 if symmetrized:
429 md = _laplace_sym(graph, graph_sum)
430 else:
431 md = _laplace(graph, graph_sum)
432 if form == "function":
433 return md, diag.astype(dtype, copy=False)
434 elif form == "lo":
435 m = _linearoperator(md, shape=graph.shape, dtype=dtype)
436 return m, diag.astype(dtype, copy=False)
437 else:
438 raise ValueError(f"Invalid form: {form!r}")
441def _laplacian_sparse(graph, normed, axis, copy, form, dtype, symmetrized):
442 # The keyword argument `form` is unused and has no effect here.
443 del form
445 if dtype is None:
446 dtype = graph.dtype
448 needs_copy = False
449 if graph.format in ('lil', 'dok'):
450 m = graph.tocoo()
451 else:
452 m = graph
453 if copy:
454 needs_copy = True
456 if symmetrized:
457 m += m.T.conj()
459 w = m.sum(axis=axis).getA1() - m.diagonal()
460 if normed:
461 m = m.tocoo(copy=needs_copy)
462 isolated_node_mask = (w == 0)
463 w = np.where(isolated_node_mask, 1, np.sqrt(w))
464 m.data /= w[m.row]
465 m.data /= w[m.col]
466 m.data *= -1
467 m.setdiag(1 - isolated_node_mask)
468 else:
469 if m.format == 'dia':
470 m = m.copy()
471 else:
472 m = m.tocoo(copy=needs_copy)
473 m.data *= -1
474 m.setdiag(w)
476 return m.astype(dtype, copy=False), w.astype(dtype)
479def _laplacian_dense_flo(graph, normed, axis, copy, form, dtype, symmetrized):
481 if copy:
482 m = np.array(graph)
483 else:
484 m = np.asarray(graph)
486 if dtype is None:
487 dtype = m.dtype
489 graph_sum = m.sum(axis=axis)
490 graph_diagonal = m.diagonal()
491 diag = graph_sum - graph_diagonal
492 if symmetrized:
493 graph_sum += m.sum(axis=1 - axis)
494 diag = graph_sum - graph_diagonal - graph_diagonal
496 if normed:
497 isolated_node_mask = diag == 0
498 w = np.where(isolated_node_mask, 1, np.sqrt(diag))
499 if symmetrized:
500 md = _laplace_normed_sym(m, graph_sum, 1.0 / w)
501 else:
502 md = _laplace_normed(m, graph_sum, 1.0 / w)
503 if form == "function":
504 return md, w.astype(dtype, copy=False)
505 elif form == "lo":
506 m = _linearoperator(md, shape=graph.shape, dtype=dtype)
507 return m, w.astype(dtype, copy=False)
508 else:
509 raise ValueError(f"Invalid form: {form!r}")
510 else:
511 if symmetrized:
512 md = _laplace_sym(m, graph_sum)
513 else:
514 md = _laplace(m, graph_sum)
515 if form == "function":
516 return md, diag.astype(dtype, copy=False)
517 elif form == "lo":
518 m = _linearoperator(md, shape=graph.shape, dtype=dtype)
519 return m, diag.astype(dtype, copy=False)
520 else:
521 raise ValueError(f"Invalid form: {form!r}")
524def _laplacian_dense(graph, normed, axis, copy, form, dtype, symmetrized):
526 if form != "array":
527 raise ValueError(f'{form!r} must be "array"')
529 if dtype is None:
530 dtype = graph.dtype
532 if copy:
533 m = np.array(graph)
534 else:
535 m = np.asarray(graph)
537 if dtype is None:
538 dtype = m.dtype
540 if symmetrized:
541 m += m.T.conj()
542 np.fill_diagonal(m, 0)
543 w = m.sum(axis=axis)
544 if normed:
545 isolated_node_mask = (w == 0)
546 w = np.where(isolated_node_mask, 1, np.sqrt(w))
547 m /= w
548 m /= w[:, np.newaxis]
549 m *= -1
550 _setdiag_dense(m, 1 - isolated_node_mask)
551 else:
552 m *= -1
553 _setdiag_dense(m, w)
555 return m.astype(dtype, copy=False), w.astype(dtype, copy=False)