Repository: scipy/scipy

Issue:
LinearOperator objects cannot be applied to sparse matrices
<!-- 
Thank you for taking the time to file a bug report. 
Please fill in the fields below, deleting the sections that 
don't apply to your issue. You can view the final output
by clicking the preview button above.
Note: This is a comment, and won't appear in the output.
-->

My issue is about how LinearOperator objects interact with sparse matrix classes (e.g. ``csc_matrix``). Currently, operations like ``A @ B`` and ``A * B`` fall back on ``dot``, which does input casting without checking for sparse matrix types. Here is the line where the casting happens:

https://github.com/scipy/scipy/blob/1e161716a5e5c827573b9b88aa790af1864dfb1d/scipy/sparse/linalg/interface.py#L412

The effect of this casting is that operations ``A @ B`` raise ValueErrors when ``B`` is a SciPy sparse matrix.

#### Reproducing code example:
<!-- 
If you place your code between the triple backticks below, 
it will be rendered as a code block. 
-->

```
import scipy.sparse as spar
import scipy.sparse.linalg as spla

I = spar.eye(5)  # any dimension will do
A = spla.aslinearoperator(I)
A @ I  # raises a ValueError
```

#### Error message:
<!-- If any, paste the *full* error message inside a code block
as above (starting from line Traceback)
-->

```
Traceback (most recent call last):
  File "/home/riley/anaconda3/envs/dev36/lib/python3.6/site-packages/IPython/core/interactiveshell.py", line 3326, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-15-7d320c9e011f>", line 1, in <module>
    A @ I
  File "/home/riley/anaconda3/envs/dev36/lib/python3.6/site-packages/scipy/sparse/linalg/interface.py", line 426, in __matmul__
    return self.__mul__(other)
  File "/home/riley/anaconda3/envs/dev36/lib/python3.6/site-packages/scipy/sparse/linalg/interface.py", line 390, in __mul__
    return self.dot(x)
  File "/home/riley/anaconda3/envs/dev36/lib/python3.6/site-packages/scipy/sparse/linalg/interface.py", line 420, in dot
    % x)
ValueError: expected 1-d or 2-d array or matrix, got array(<5x5 sparse matrix of type '<class 'numpy.float64'>'
	with 5 stored elements (1 diagonals) in DIAgonal format>, dtype=object)
```

#### Scipy/Numpy/Python version information:
SciPy: 1.4.1.
NumPy: 1.17.1.
Python 3.6.7.



Discussion:
What would be the desired behavior here?
- Silently returning a sparse matrix/array of the same type seems wrong because there's no promise it'll be actually sparse.
- Wrapping the sparse matrix into `aslinearoperator` and returning a product seems like a good option, but I am not sure whether it's what the user wants.

I believe raising an informative error would be the best; do people agree?
Hi @akhmerov! Thanks for checking in on this. I think that if someone is using LinearOperator object ``A`` or ``B``, then their most likely assumption will be that ``A @ B`` returns a LinearOperator whenever possible. Certainly, there are limitations where duck typing will only get you so far. But from a mathematical standpoint, a sparse matrix is a linear operator. Since SciPy's LinearOperator class is defined in ``scipy.sparse.linalg`` a user can reasonably expect that SciPy sparse matrices play nice with that class.

That said -- raising an informative error message would be okay, in my opinion. I'd just want the error to recommend a fix, like
```
ValueError : computing A @ B where one of (A, B) is a LinearOperator and the other of (A, B) is a sparse matrix.
If A is the sparse matrix, consider computing A_lo @ B where A_lo = scipy.sparse.linalg.aslinearoperator(A).
If B is the sparse matrix, consider computing A @ B_lo where B_lo = scipy.sparse.linalg.aslinearoperator(B).
```


Alright; happy to provide a more specific error message once my own #18061 editing the same code is accepted.

Resolving patch:
diff --git a/scipy/sparse/linalg/_interface.py b/scipy/sparse/linalg/_interface.py
--- a/scipy/sparse/linalg/_interface.py
+++ b/scipy/sparse/linalg/_interface.py
@@ -325,8 +325,8 @@ def matmat(self, X):
         _matmat method to ensure that y has the correct type.
 
         """
-
-        X = np.asanyarray(X)
+        if not (isspmatrix(X) or is_pydata_spmatrix(X)):
+            X = np.asanyarray(X)
 
         if X.ndim != 2:
             raise ValueError('expected 2-d ndarray or matrix, not %d-d'
@@ -336,7 +336,15 @@ def matmat(self, X):
             raise ValueError('dimension mismatch: %r, %r'
                              % (self.shape, X.shape))
 
-        Y = self._matmat(X)
+        try:
+            Y = self._matmat(X)
+        except Exception as e:
+            if isspmatrix(X) or is_pydata_spmatrix(X):
+                raise TypeError(
+                    "Unable to multiply a LinearOperator with a sparse matrix."
+                    " Wrap the matrix in aslinearoperator first."
+                ) from e
+            raise
 
         if isinstance(Y, np.matrix):
             Y = asmatrix(Y)
@@ -365,8 +373,8 @@ def rmatmat(self, X):
         This rmatmat wraps the user-specified rmatmat routine.
 
         """
-
-        X = np.asanyarray(X)
+        if not (isspmatrix(X) or is_pydata_spmatrix(X)):
+            X = np.asanyarray(X)
 
         if X.ndim != 2:
             raise ValueError('expected 2-d ndarray or matrix, not %d-d'
@@ -376,7 +384,16 @@ def rmatmat(self, X):
             raise ValueError('dimension mismatch: %r, %r'
                              % (self.shape, X.shape))
 
-        Y = self._rmatmat(X)
+        try:
+            Y = self._rmatmat(X)
+        except Exception as e:
+            if isspmatrix(X) or is_pydata_spmatrix(X):
+                raise TypeError(
+                    "Unable to multiply a LinearOperator with a sparse matrix."
+                    " Wrap the matrix in aslinearoperator() first."
+                ) from e
+            raise
+
         if isinstance(Y, np.matrix):
             Y = asmatrix(Y)
         return Y
@@ -414,7 +431,9 @@ def dot(self, x):
         elif np.isscalar(x):
             return _ScaledLinearOperator(self, x)
         else:
-            x = np.asarray(x)
+            if not isspmatrix(x) and not is_pydata_spmatrix(x):
+                # Sparse matrices shouldn't be converted to numpy arrays.
+                x = np.asarray(x)
 
             if x.ndim == 1 or x.ndim == 2 and x.shape[1] == 1:
                 return self.matvec(x)
@@ -465,7 +484,9 @@ def _rdot(self, x):
         elif np.isscalar(x):
             return _ScaledLinearOperator(self, x)
         else:
-            x = np.asarray(x)
+            if not isspmatrix(x) and not is_pydata_spmatrix(x):
+                # Sparse matrices shouldn't be converted to numpy arrays.
+                x = np.asarray(x)
 
             # We use transpose instead of rmatvec/rmatmat to avoid
             # unnecessary complex conjugation if possible.


Test patch:
diff --git a/scipy/sparse/linalg/tests/test_interface.py b/scipy/sparse/linalg/tests/test_interface.py
--- a/scipy/sparse/linalg/tests/test_interface.py
+++ b/scipy/sparse/linalg/tests/test_interface.py
@@ -451,3 +451,16 @@ def test_transpose_noconjugate():
 
     assert_equal(B.dot(v), Y.dot(v))
     assert_equal(B.T.dot(v), Y.T.dot(v))
+
+def test_sparse_matmat_exception():
+    A = interface.LinearOperator((2, 2), matvec=lambda x: x)
+    B = sparse.identity(2)
+    msg = "Unable to multiply a LinearOperator with a sparse matrix."
+    with assert_raises(TypeError, match=msg):
+        A @ B
+    with assert_raises(TypeError, match=msg):
+        B @ A
+    with assert_raises(ValueError):
+        A @ np.identity(4)
+    with assert_raises(ValueError):
+        np.identity(4) @ A