Repository: scipy/scipy

Issue:
ENH: Remove ._is_array attribute from sparse arrays
### Is your feature request related to a problem? Please describe.

Currently there are two ways to check if one of the sparse types is an array type.

* `a._is_array`
* `isinstance(a, sparse.sparray)`

It's probably good to consolidate on a single way to do things.

### Describe the solution you'd like.

`isinstance(a, sparse.sparray)`

It's public, it's pythonic. "There should be one -- and preferably only one -- obvious way to do it."

### Describe alternatives you've considered.

We could keep an attribute. But I can't think of any cases which `a._is_array` allows that aren't equivalent to an instance check.

### Additional context (e.g. screenshots, GIFs)

I believe `_is_array` was previously included as a way of indicating array-ness while the array classes still inherited from the matrix classes.

Since @dschult's refactoring of the class hierarchy in #18531, we can use the `sparray` and `spmatrix` public classes to differentiate between arrays and matrices via `isinstance` checks.


Discussion:
+1 for removing the attribute, now that we have a proper class hierarchy for doing isinstance checks.

Resolving patch:
diff --git a/scipy/sparse/_base.py b/scipy/sparse/_base.py
--- a/scipy/sparse/_base.py
+++ b/scipy/sparse/_base.py
@@ -103,8 +103,6 @@ def _lil_container(self):
         from ._lil import lil_array
         return lil_array
 
-    _is_array = True
-
     def __init__(self, maxprint=MAXPRINT):
         self._shape = None
         if self.__class__.__name__ == '_spbase':
@@ -220,14 +218,14 @@ def astype(self, dtype, casting='unsafe', copy=True):
 
     @classmethod
     def _ascontainer(cls, X, **kwargs):
-        if cls._is_array:
+        if issubclass(cls, sparray):
             return np.asarray(X, **kwargs)
         else:
             return asmatrix(X, **kwargs)
 
     @classmethod
     def _container(cls, X, **kwargs):
-        if cls._is_array:
+        if issubclass(cls, sparray):
             return np.array(X, **kwargs)
         else:
             return matrix(X, **kwargs)
@@ -299,7 +297,7 @@ def format(self):
 
     def __repr__(self):
         _, format_name = _formats[self.format]
-        sparse_cls = 'array' if self._is_array else 'matrix'
+        sparse_cls = 'array' if isinstance(self, sparray) else 'matrix'
         return f"<%dx%d sparse {sparse_cls} of type '%s'\n" \
                "\twith %d stored elements in %s format>" % \
                (self.shape + (self.dtype.type, self.nnz, format_name))
@@ -715,7 +713,7 @@ def __pow__(self, *args, **kwargs):
 
     @property
     def A(self) -> np.ndarray:
-        if self._is_array:
+        if isinstance(self, sparray):
             warn(np.VisibleDeprecationWarning(
                 "`.A` is deprecated and will be removed in v1.13.0. "
                 "Use `.toarray()` instead."
@@ -728,7 +726,7 @@ def T(self):
 
     @property
     def H(self):
-        if self._is_array:
+        if isinstance(self, sparray):
             warn(np.VisibleDeprecationWarning(
                 "`.H` is deprecated and will be removed in v1.13.0. "
                 "Please use `.T.conjugate()` instead."
@@ -1286,7 +1284,7 @@ def _get_index_dtype(self, arrays=(), maxval=None, check_contents=False):
         from ._sputils import get_index_dtype
 
         # Don't check contents for array API
-        return get_index_dtype(arrays, maxval, (check_contents and not self._is_array))
+        return get_index_dtype(arrays, maxval, (check_contents and not isinstance(self, sparray)))
 
 
     ## All methods below are deprecated and should be removed in
diff --git a/scipy/sparse/_index.py b/scipy/sparse/_index.py
--- a/scipy/sparse/_index.py
+++ b/scipy/sparse/_index.py
@@ -34,7 +34,9 @@ def _raise_on_1d_array_slice(self):
 
         Once 1D sparse arrays are implemented, it should be removed.
         """
-        if self._is_array:
+        from scipy.sparse import sparray
+
+        if isinstance(self, sparray):
             raise NotImplementedError(
                 'We have not yet implemented 1D sparse slices; '
                 'please index using explicit indices, e.g. `x[:, [0]]`'
diff --git a/scipy/sparse/_matrix.py b/scipy/sparse/_matrix.py
--- a/scipy/sparse/_matrix.py
+++ b/scipy/sparse/_matrix.py
@@ -6,7 +6,6 @@ class spmatrix:
 
     It cannot be instantiated.  Most of the work is provided by subclasses.
     """
-    _is_array = False
 
     @property
     def _bsr_container(self):


Test patch:
diff --git a/scipy/sparse/tests/test_array_api.py b/scipy/sparse/tests/test_array_api.py
--- a/scipy/sparse/tests/test_array_api.py
+++ b/scipy/sparse/tests/test_array_api.py
@@ -98,10 +98,10 @@ def test_indexing(A):
     with pytest.raises(NotImplementedError):
         A[[1, 2], 1]
 
-    assert A[[0]]._is_array, "Expected sparse array, got sparse matrix"
-    assert A[1, [[1, 2]]]._is_array, "Expected ndarray, got sparse array"
-    assert A[[[1, 2]], 1]._is_array, "Expected ndarray, got sparse array"
-    assert A[:, [1, 2]]._is_array, "Expected sparse array, got something else"
+    assert isinstance(A[[0]], scipy.sparse.sparray), "Expected sparse array, got sparse matrix"
+    assert isinstance(A[1, [[1, 2]]], scipy.sparse.sparray), "Expected ndarray, got sparse array"
+    assert isinstance(A[[[1, 2]], 1], scipy.sparse.sparray), "Expected ndarray, got sparse array"
+    assert isinstance(A[:, [1, 2]], scipy.sparse.sparray), "Expected sparse array, got something else"
 
 
 @parametrize_sparrays
@@ -112,7 +112,7 @@ def test_dense_addition(A):
 
 @parametrize_sparrays
 def test_sparse_addition(A):
-    assert (A + A)._is_array, "Expected array, got matrix"
+    assert isinstance((A + A), scipy.sparse.sparray), "Expected array, got matrix"
 
 
 @parametrize_sparrays
@@ -140,8 +140,8 @@ def test_matmul(A):
 
 @parametrize_square_sparrays
 def test_pow(B):
-    assert (B**0)._is_array, "Expected array, got matrix"
-    assert (B**2)._is_array, "Expected array, got matrix"
+    assert isinstance((B**0), scipy.sparse.sparray), "Expected array, got matrix"
+    assert isinstance((B**2), scipy.sparse.sparray), "Expected array, got matrix"
 
 
 @parametrize_sparrays
@@ -151,11 +151,11 @@ def test_sparse_divide(A):
 @parametrize_sparrays
 def test_sparse_dense_divide(A):
     with pytest.warns(RuntimeWarning):
-        assert (A / A.todense())._is_array
+        assert isinstance((A / A.todense()), scipy.sparse.sparray)
 
 @parametrize_sparrays
 def test_dense_divide(A):
-    assert (A / 2)._is_array, "Expected array, got matrix"
+    assert isinstance((A / 2), scipy.sparse.sparray), "Expected array, got matrix"
 
 
 @parametrize_sparrays
@@ -172,8 +172,8 @@ def test_no_H_attr(A):
 
 @parametrize_sparrays
 def test_getrow_getcol(A):
-    assert A._getcol(0)._is_array
-    assert A._getrow(0)._is_array
+    assert isinstance(A._getcol(0), scipy.sparse.sparray)
+    assert isinstance(A._getrow(0), scipy.sparse.sparray)
 
 
 # -- linalg --
@@ -191,7 +191,7 @@ def test_inv(B):
 
     C = spla.inv(B)
 
-    assert C._is_array
+    assert isinstance(C, scipy.sparse.sparray)
     npt.assert_allclose(C.todense(), np.linalg.inv(B.todense()))
 
 
@@ -204,7 +204,7 @@ def test_expm(B):
 
     C = spla.expm(B)
 
-    assert C._is_array
+    assert isinstance(C, scipy.sparse.sparray)
     npt.assert_allclose(
         C.todense(),
         spla.expm(Bmat).todense()
@@ -405,53 +405,53 @@ def test_index_dtype_compressed(cls, indices_attrs, expected_dtype):
 
 def test_default_is_matrix_diags():
     m = scipy.sparse.diags([0, 1, 2])
-    assert not m._is_array
+    assert not isinstance(m, scipy.sparse.sparray)
 
 
 def test_default_is_matrix_eye():
     m = scipy.sparse.eye(3)
-    assert not m._is_array
+    assert not isinstance(m, scipy.sparse.sparray)
 
 
 def test_default_is_matrix_spdiags():
     m = scipy.sparse.spdiags([1, 2, 3], 0, 3, 3)
-    assert not m._is_array
+    assert not isinstance(m, scipy.sparse.sparray)
 
 
 def test_default_is_matrix_identity():
     m = scipy.sparse.identity(3)
-    assert not m._is_array
+    assert not isinstance(m, scipy.sparse.sparray)
 
 
 def test_default_is_matrix_kron_dense():
     m = scipy.sparse.kron(
         np.array([[1, 2], [3, 4]]), np.array([[4, 3], [2, 1]])
     )
-    assert not m._is_array
+    assert not isinstance(m, scipy.sparse.sparray)
 
 
 def test_default_is_matrix_kron_sparse():
     m = scipy.sparse.kron(
         np.array([[1, 2], [3, 4]]), np.array([[1, 0], [0, 0]])
     )
-    assert not m._is_array
+    assert not isinstance(m, scipy.sparse.sparray)
 
 
 def test_default_is_matrix_kronsum():
     m = scipy.sparse.kronsum(
         np.array([[1, 0], [0, 1]]), np.array([[0, 1], [1, 0]])
     )
-    assert not m._is_array
+    assert not isinstance(m, scipy.sparse.sparray)
 
 
 def test_default_is_matrix_random():
     m = scipy.sparse.random(3, 3)
-    assert not m._is_array
+    assert not isinstance(m, scipy.sparse.sparray)
 
 
 def test_default_is_matrix_rand():
     m = scipy.sparse.rand(3, 3)
-    assert not m._is_array
+    assert not isinstance(m, scipy.sparse.sparray)
 
 
 @pytest.mark.parametrize("fn", (scipy.sparse.hstack, scipy.sparse.vstack))
@@ -461,7 +461,7 @@ def test_default_is_matrix_stacks(fn):
     A = scipy.sparse.coo_matrix(np.eye(2))
     B = scipy.sparse.coo_matrix([[0, 1], [1, 0]])
     m = fn([A, B])
-    assert not m._is_array
+    assert not isinstance(m, scipy.sparse.sparray)
 
 
 def test_blocks_default_construction_fn_matrices():
@@ -473,11 +473,11 @@ def test_blocks_default_construction_fn_matrices():
 
     # block diag
     m = scipy.sparse.block_diag((A, B, C))
-    assert not m._is_array
+    assert not isinstance(m, scipy.sparse.sparray)
 
     # bmat
     m = scipy.sparse.bmat([[A, None], [None, C]])
-    assert not m._is_array
+    assert not isinstance(m, scipy.sparse.sparray)
 
 
 def test_format_property():
@@ -493,8 +493,8 @@ def test_format_property():
 def test_issparse():
     m = scipy.sparse.eye(3)
     a = scipy.sparse.csr_array(m)
-    assert not m._is_array
-    assert a._is_array
+    assert not isinstance(m, scipy.sparse.sparray)
+    assert isinstance(a, scipy.sparse.sparray)
 
     # Both sparse arrays and sparse matrices should be sparse
     assert scipy.sparse.issparse(a)
@@ -508,8 +508,8 @@ def test_issparse():
 def test_isspmatrix():
     m = scipy.sparse.eye(3)
     a = scipy.sparse.csr_array(m)
-    assert not m._is_array
-    assert a._is_array
+    assert not isinstance(m, scipy.sparse.sparray)
+    assert isinstance(a, scipy.sparse.sparray)
 
     # Should only be true for sparse matrices, not sparse arrays
     assert not scipy.sparse.isspmatrix(a)
@@ -535,8 +535,8 @@ def test_isspmatrix():
 def test_isspmatrix_format(fmt, fn):
     m = scipy.sparse.eye(3, format=fmt)
     a = scipy.sparse.csr_array(m).asformat(fmt)
-    assert not m._is_array
-    assert a._is_array
+    assert not isinstance(m, scipy.sparse.sparray)
+    assert isinstance(a, scipy.sparse.sparray)
 
     # Should only be true for sparse matrices, not sparse arrays
     assert not fn(a)
diff --git a/scipy/sparse/tests/test_base.py b/scipy/sparse/tests/test_base.py
--- a/scipy/sparse/tests/test_base.py
+++ b/scipy/sparse/tests/test_base.py
@@ -33,7 +33,7 @@ class for generic tests" section.
 import scipy.sparse as sparse
 from scipy.sparse import (csc_matrix, csr_matrix, dok_matrix,
         coo_matrix, lil_matrix, dia_matrix, bsr_matrix,
-        eye, issparse, SparseEfficiencyWarning)
+        eye, issparse, SparseEfficiencyWarning, sparray)
 from scipy.sparse._sputils import (supported_dtypes, isscalarlike,
                                    get_index_dtype, asmatrix, matrix)
 from scipy.sparse.linalg import splu, expm, inv
@@ -249,11 +249,6 @@ def init_class(cls):
         cls.dat = array([[1, 0, 0, 2], [3, 0, 1, 0], [0, 2, 0, 0]], 'd')
         cls.datsp = cls.spcreator(cls.dat)
 
-        # set array/matrix testing mode for this class based on the class attribute
-        # Could use spcreator._is_array except that some test classes (e.g. TextCSR)
-        # use a method to filter warnings produced when creating the sparse object.
-        cls._is_array = cls.datsp._is_array
-
         # Some sparse and dense matrices with data for every supported dtype.
         # This set union is a workaround for numpy#6295, which means that
         # two np.int64 dtypes don't hash to the same value.
@@ -1608,7 +1603,7 @@ def test_start_vs_at_sign_for_sparray_and_spmatrix(self):
         # test that * is matmul for spmatrix and mul for sparray
         A = self.spcreator([[1],[2],[3]])
 
-        if A._is_array:
+        if isinstance(A, sparray):
             assert_array_almost_equal(A * np.ones((3,1)), A)
             assert_array_almost_equal(A * array([[1]]), A)
             assert_array_almost_equal(A * np.ones((3,1)), A)
@@ -1668,7 +1663,7 @@ def test_matmul(self):
         assert_array_almost_equal(matmul(M, B).toarray(), (M @ B).toarray())
         assert_array_almost_equal(matmul(M.toarray(), B), (M @ B).toarray())
         assert_array_almost_equal(matmul(M, B.toarray()), (M @ B).toarray())
-        if not M._is_array:
+        if not isinstance(M, sparray):
             assert_array_almost_equal(matmul(M, B).toarray(), (M * B).toarray())
             assert_array_almost_equal(matmul(M.toarray(), B), (M * B).toarray())
             assert_array_almost_equal(matmul(M, B.toarray()), (M * B).toarray())
@@ -1760,7 +1755,7 @@ def test_matmat_sparse(self):
         A = self.spcreator([[1,2],[3,4]])
         B = self.spcreator([[1,2],[3,4],[5,6]])
         assert_raises(ValueError, A.__matmul__, B)
-        if A._is_array:
+        if isinstance(A, sparray):
             assert_raises(ValueError, A.__mul__, B)
 
     def test_matmat_dense(self):
@@ -2157,7 +2152,7 @@ def test_inplace_dense(self):
 
         x = a.copy()
         y = a.copy()
-        if b._is_array:
+        if isinstance(b, sparray):
             assert_raises(ValueError, operator.imul, x, b.T)
             x = x * a
             y *= b