Repository: scipy/scipy

Issue:
Graph Laplacian sparse format change ruins the in-place calculation 
**Is your feature request related to a problem? Please describe.**
Since https://github.com/scipy/scipy/pull/15251 the function https://docs.scipy.org/doc/scipy-1.3.0/reference/generated/scipy.sparse.csgraph.laplacian.html now support in-place calculation, without doubling the amount of memory on the output , cf. https://github.com/scikit-learn/scikit-learn/pull/14636

However, it appears that the code is not preserving the format other than COO of the graph sparse matrix - the implementation explicitly makes a copy unless the input format is COO, as at least now documented in https://github.com/scipy/scipy/pull/15251.  It's really unclear why. 

**Describe the solution you'd like**
In-place and strictly format- and type-preserving calculation with `copy=False` avoiding the memory usage spikes. Cf. https://github.com/scipy/scipy/issues/10827



Discussion:
Seems reasonable. Can we combine gh-10827 and this? I think it's the same thing right?
> 
> 
> Seems reasonable. Can we combine [gh-10827](https://github.com/scipy/scipy/issues/10827) and this? I think it's the same thing right?

The same code and similar goals, but different and unrelated changes. May be better finish one and have it merged, then start the other, to simplify code writing and unit testing. This one would have immediate positive effects in scikit-learn with trivial changes in scikit-learn code base, so is more practically important.
Makes sense. 

Looking at `_laplacian_sparse`, the implementation explicitly makes a copy unless the input format is COO, and this isn't documented. The scikit-learn PR seems to just add a `copy=True` keyword, but what seems to be needed first is an implementation that doesn't need conversion to COO. 

And then it's not 100% clear to me what the API request is: add `out=`, or really _overwrite_ the input matrix?
@rgommers  Whatever is the most memory efficient and without too much computing and coding. 

Resolving patch:
diff --git a/scipy/sparse/csgraph/_laplacian.py b/scipy/sparse/csgraph/_laplacian.py
--- a/scipy/sparse/csgraph/_laplacian.py
+++ b/scipy/sparse/csgraph/_laplacian.py
@@ -5,6 +5,7 @@
 # Authors: Aric Hagberg <hagberg@lanl.gov>
 #          Gael Varoquaux <gael.varoquaux@normalesup.org>
 #          Jake Vanderplas <vanderplas@astro.washington.edu>
+#          Andrew Knyazev <Andrew.Knyazev@ucdenver.edu>
 # License: BSD
 
 import numpy as np
@@ -13,7 +14,8 @@
 
 ###############################################################################
 # Graph laplacian
-def laplacian(csgraph, normed=False, return_diag=False, use_out_degree=False):
+def laplacian(csgraph, normed=False, return_diag=False, use_out_degree=False,
+              *, copy=True):
     """
     Return the Laplacian matrix of a directed graph.
 
@@ -23,12 +25,18 @@ def laplacian(csgraph, normed=False, return_diag=False, use_out_degree=False):
         compressed-sparse graph, with shape (N, N).
     normed : bool, optional
         If True, then compute symmetric 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.
 
     Returns
     -------
@@ -43,9 +51,27 @@ def laplacian(csgraph, normed=False, return_diag=False, use_out_degree=False):
     Notes
     -----
     The Laplacian matrix of a graph is sometimes referred to as the
-    "Kirchoff matrix" or the "admittance matrix", and is useful in many
+    "Kirchoff matrix" or just the "Laplacian", and is useful in many
     parts of spectral graph theory. In particular, the eigen-decomposition
-    of the laplacian matrix can give insight into many properties of the graph.
+    of the Laplacian can give insight into many properties of the graph, e.g.,
+    is commonly used for spectal data enmedding and clustering.
+
+    The constructed Laplacian doubles the memory use if ``copy=True``,
+    which is the default. Choosing ``copy=False`` has no effect unless
+    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 the input adjacency matrix is not symmetic, the Laplacian is also
+    non-symmetric and may need to be symmetrized; e.g., ``lap += lap.T``,
+    before the eigen-decomposition.
+
+    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
+    zeros, negative, or complex with a non-zero imaginary part values.
 
     Examples
     --------
@@ -73,7 +99,8 @@ def laplacian(csgraph, normed=False, return_diag=False, use_out_degree=False):
 
     create_lap = _laplacian_sparse if isspmatrix(csgraph) else _laplacian_dense
     degree_axis = 1 if use_out_degree else 0
-    lap, d = create_lap(csgraph, normed=normed, axis=degree_axis)
+    lap, d = create_lap(csgraph, normed=normed, axis=degree_axis,
+                        copy=copy)
     if return_diag:
         return lap, d
     return lap
@@ -83,13 +110,15 @@ def _setdiag_dense(A, d):
     A.flat[::len(d)+1] = d
 
 
-def _laplacian_sparse(graph, normed=False, axis=0):
+def _laplacian_sparse(graph, normed=False, axis=0,
+                      copy=True):
+    needs_copy = False
     if graph.format in ('lil', 'dok'):
         m = graph.tocoo()
-        needs_copy = False
     else:
         m = graph
-        needs_copy = True
+        if copy:
+            needs_copy = True
     w = m.sum(axis=axis).getA1() - m.diagonal()
     if normed:
         m = m.tocoo(copy=needs_copy)
@@ -109,8 +138,13 @@ def _laplacian_sparse(graph, normed=False, axis=0):
     return m, w
 
 
-def _laplacian_dense(graph, normed=False, axis=0):
-    m = np.array(graph)
+def _laplacian_dense(graph, normed=False, axis=0,
+                     copy=True):
+    if copy:
+        m = np.array(graph)
+    else:
+        m = np.asarray(graph)
+
     np.fill_diagonal(m, 0)
     w = m.sum(axis=axis)
     if normed:


Test patch:
diff --git a/scipy/sparse/csgraph/tests/test_graph_laplacian.py b/scipy/sparse/csgraph/tests/test_graph_laplacian.py
--- a/scipy/sparse/csgraph/tests/test_graph_laplacian.py
+++ b/scipy/sparse/csgraph/tests/test_graph_laplacian.py
@@ -1,14 +1,26 @@
 # Author: Gael Varoquaux <gael.varoquaux@normalesup.org>
 #         Jake Vanderplas <vanderplas@astro.washington.edu>
+#         Andrew Knyazev <Andrew.Knyazev@ucdenver.edu>
 # License: BSD
+import pytest
 import numpy as np
-from numpy.testing import assert_allclose, assert_array_almost_equal
+from numpy.testing import assert_allclose
 from pytest import raises as assert_raises
 from scipy import sparse
 
 from scipy.sparse import csgraph
 
 
+def test_laplacian_value_error():
+    for t in int, float, complex:
+        for m in ([1, 1],
+                  [[[1]]],
+                  [[1, 2, 3], [4, 5, 6]],
+                  [[1, 2], [3, 4], [5, 5]]):
+            A = np.array(m, dtype=t)
+            assert_raises(ValueError, csgraph.laplacian, A)
+
+
 def _explicit_laplacian(x, normed=False):
     if sparse.issparse(x):
         x = x.toarray()
@@ -24,7 +36,7 @@ def _explicit_laplacian(x, normed=False):
     return y
 
 
-def _check_symmetric_graph_laplacian(mat, normed):
+def _check_symmetric_graph_laplacian(mat, normed, copy=True):
     if not hasattr(mat, 'shape'):
         mat = eval(mat, dict(np=np, sparse=sparse))
 
@@ -34,26 +46,32 @@ def _check_symmetric_graph_laplacian(mat, normed):
     else:
         sp_mat = sparse.csr_matrix(mat)
 
-    laplacian = csgraph.laplacian(mat, normed=normed)
+    mat_copy = np.copy(mat)
+    sp_mat_copy = sparse.csr_matrix(sp_mat, copy=True)
+
     n_nodes = mat.shape[0]
-    if not normed:
-        assert_array_almost_equal(laplacian.sum(axis=0), np.zeros(n_nodes))
-    assert_array_almost_equal(laplacian.T, laplacian)
-    assert_array_almost_equal(
-        laplacian, csgraph.laplacian(sp_mat, normed=normed).toarray())
+    explicit_laplacian = _explicit_laplacian(mat, normed=normed)
+    laplacian = csgraph.laplacian(mat, normed=normed, copy=copy)
+    sp_laplacian = csgraph.laplacian(sp_mat, normed=normed,
+                                     copy=copy)
+
+    if copy:
+        assert_allclose(mat, mat_copy)
+        _assert_allclose_sparse(sp_mat, sp_mat_copy)
+    else:
+        if not (normed and (np.issubdtype(mat.dtype, np.signedinteger)
+                            or np.issubdtype(mat.dtype, np.uint))):
+            assert_allclose(laplacian, mat)
+            if sp_mat.format == 'coo':
+                _assert_allclose_sparse(sp_laplacian, sp_mat)
 
-    assert_array_almost_equal(laplacian,
-            _explicit_laplacian(mat, normed=normed))
+    assert_allclose(laplacian, sp_laplacian.toarray())
 
-
-def test_laplacian_value_error():
-    for t in int, float, complex:
-        for m in ([1, 1],
-                  [[[1]]],
-                  [[1, 2, 3], [4, 5, 6]],
-                  [[1, 2], [3, 4], [5, 5]]):
-            A = np.array(m, dtype=t)
-            assert_raises(ValueError, csgraph.laplacian, A)
+    for tested in [laplacian, sp_laplacian.toarray()]:
+        if not normed:
+            assert_allclose(tested.sum(axis=0), np.zeros(n_nodes))
+        assert_allclose(tested.T, tested)
+        assert_allclose(tested, explicit_laplacian)
 
 
 def test_symmetric_graph_laplacian():
@@ -66,9 +84,10 @@ def test_symmetric_graph_laplacian():
         'sparse.diags([1, 1], [-1, 1], shape=(4, 4)).todense()',
         'np.vander(np.arange(4)) + np.vander(np.arange(4)).T'
     )
-    for mat_str in symmetric_mats:
+    for mat in symmetric_mats:
         for normed in True, False:
-            _check_symmetric_graph_laplacian(mat_str, normed)
+            for copy in True, False:
+                _check_symmetric_graph_laplacian(mat, normed, copy)
 
 
 def _assert_allclose_sparse(a, b, **kwargs):
@@ -76,61 +95,90 @@ def _assert_allclose_sparse(a, b, **kwargs):
     if sparse.issparse(a):
         a = a.toarray()
     if sparse.issparse(b):
-        b = a.toarray()
+        b = b.toarray()
     assert_allclose(a, b, **kwargs)
 
 
-def _check_laplacian(A, desired_L, desired_d, normed, use_out_degree):
-    for arr_type in np.array, sparse.csr_matrix, sparse.coo_matrix:
-        for t in int, float, complex:
-            adj = arr_type(A, dtype=t)
-            L = csgraph.laplacian(adj, normed=normed, return_diag=False,
-                                  use_out_degree=use_out_degree)
-            _assert_allclose_sparse(L, desired_L, atol=1e-12)
-            L, d = csgraph.laplacian(adj, normed=normed, return_diag=True,
-                                  use_out_degree=use_out_degree)
-            _assert_allclose_sparse(L, desired_L, atol=1e-12)
-            _assert_allclose_sparse(d, desired_d, atol=1e-12)
-
-
-def test_asymmetric_laplacian():
+def _check_laplacian(A, desired_L, desired_d,
+                     normed, use_out_degree, copy, dtype, arr_type):
+    mat = arr_type(A, dtype=dtype)
+    L, d = csgraph.laplacian(mat, normed=normed, return_diag=True,
+                             use_out_degree=use_out_degree,
+                             copy=copy)
+    _assert_allclose_sparse(L, desired_L, atol=1e-12)
+    _assert_allclose_sparse(d, desired_d, atol=1e-12)
+
+    if not copy:
+        if not (normed and (np.issubdtype(mat.dtype, np.signedinteger)
+                            or np.issubdtype(mat.dtype, np.uint))):
+            if type(mat) is np.ndarray:
+                assert_allclose(L, mat)
+            elif mat.format == 'coo':
+                _assert_allclose_sparse(L, mat)
+
+
+REAL_DTYPES = {np.intc, np.int_, np.longlong,
+               np.single, np.double, np.longdouble}
+COMPLEX_DTYPES = {np.csingle, np.cdouble, np.clongdouble}
+# use sorted tuple to ensure fixed order of tests
+DTYPES = tuple(sorted(REAL_DTYPES ^ COMPLEX_DTYPES, key=str))
+
+
+@pytest.mark.parametrize("dtype", DTYPES)
+@pytest.mark.parametrize("arr_type", [np.array,
+                                      sparse.csr_matrix,
+                                      sparse.coo_matrix])
+@pytest.mark.parametrize("copy", [True, False])
+@pytest.mark.parametrize("normed", [True, False])
+@pytest.mark.parametrize("use_out_degree", [True, False])
+def test_asymmetric_laplacian(use_out_degree, normed,
+                              copy, dtype, arr_type):
     # adjacency matrix
     A = [[0, 1, 0],
          [4, 2, 0],
          [0, 0, 0]]
-
-    # Laplacian matrix using out-degree
-    L = [[1, -1, 0],
-         [-4, 4, 0],
-         [0, 0, 0]]
-    d = [1, 4, 0]
-    _check_laplacian(A, L, d, normed=False, use_out_degree=True)
-
-    # normalized Laplacian matrix using out-degree
-    L = [[1, -0.5, 0],
-         [-2, 1, 0],
-         [0, 0, 0]]
-    d = [1, 2, 1]
-    _check_laplacian(A, L, d, normed=True, use_out_degree=True)
-
-    # Laplacian matrix using in-degree
-    L = [[4, -1, 0],
-         [-4, 1, 0],
-         [0, 0, 0]]
-    d = [4, 1, 0]
-    _check_laplacian(A, L, d, normed=False, use_out_degree=False)
-
-    # normalized Laplacian matrix using in-degree
-    L = [[1, -0.5, 0],
-         [-2, 1, 0],
-         [0, 0, 0]]
-    d = [2, 1, 1]
-    _check_laplacian(A, L, d, normed=True, use_out_degree=False)
-
-
-def test_sparse_formats():
-    for fmt in ('csr', 'csc', 'coo', 'lil', 'dok', 'dia', 'bsr'):
-        mat = sparse.diags([1, 1], [-1, 1], shape=(4,4), format=fmt)
-        for normed in True, False:
-            _check_symmetric_graph_laplacian(mat, normed)
-
+    A = arr_type(np.array(A), dtype=dtype)
+
+    if not normed and use_out_degree:
+        # Laplacian matrix using out-degree
+        L = [[1, -1, 0],
+             [-4, 4, 0],
+             [0, 0, 0]]
+        d = [1, 4, 0]
+
+    if normed and use_out_degree:
+        # normalized Laplacian matrix using out-degree
+        L = [[1, -0.5, 0],
+             [-2, 1, 0],
+             [0, 0, 0]]
+        d = [1, 2, 1]
+
+    if not normed and not use_out_degree:
+        # Laplacian matrix using in-degree
+        L = [[4, -1, 0],
+             [-4, 1, 0],
+             [0, 0, 0]]
+        d = [4, 1, 0]
+
+    if normed and not use_out_degree:
+        # normalized Laplacian matrix using in-degree
+        L = [[1, -0.5, 0],
+             [-2, 1, 0],
+             [0, 0, 0]]
+        d = [2, 1, 1]
+
+    _check_laplacian(A, L, d,
+                     normed=normed,
+                     use_out_degree=use_out_degree,
+                     copy=copy,
+                     dtype=dtype,
+                     arr_type=arr_type)
+
+
+@pytest.mark.parametrize("fmt", ['csr', 'csc', 'coo', 'lil',
+                                 'dok', 'dia', 'bsr'])
+@pytest.mark.parametrize("normed", [True, False])
+@pytest.mark.parametrize("copy", [True, False])
+def test_sparse_formats(fmt, normed, copy):
+    mat = sparse.diags([1, 1], [-1, 1], shape=(4, 4), format=fmt)
+    _check_symmetric_graph_laplacian(mat, normed, copy)