Repository: scipy/scipy

Issue:
BUG: stacking two dok_array produces a NotImplementedError about 1D sparse slices
### Describe your issue.

In scipy 1.12 it looks like trying to stack two dok_array produces an error. It works fine in scipy 1.11. I also checked that the error still happens in the scipy development wheel.

I did not found an issue about this but maybe I have missed it.

### Reproducing Code Example

```python
import scipy.sparse as sp
import numpy as np

X_dense = np.array([[1, 0, 0, 2], [0, 4, 1, 0], [0, 0, 5, 0]])
X_sparse = sp.dok_array(X_dense)

sp.hstack((X_sparse, X_sparse))
```

Full disclosure in the original issue in scikit-learn we were actually trying to do something closer to https://github.com/scipy/scipy/issues/20060#issuecomment-1936020362, i.e. stack two sparse arrays of different format.

### Error message

```shell
---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
Cell In[1], line 7
      4 X_dense = np.array([[1, 0, 0, 2], [0, 4, 1, 0], [0, 0, 5, 0]])
      5 X_sparse = sp.dok_array(X_dense)
----> 7 sp.hstack((X_sparse, X_sparse))

File ~/micromamba/envs/del2/lib/python3.12/site-packages/scipy/sparse/_construct.py:729, in hstack(blocks, format, dtype)
    689 def hstack(blocks, format=None, dtype=None):
    690     """
    691     Stack sparse matrices horizontally (column wise)
    692 
   (...)
    727 
    728     """
--> 729     blocks = np.asarray(blocks, dtype='object')
    730     if any(isinstance(b, sparray) for b in blocks.flat):
    731         return _block([blocks], format, dtype)

File ~/micromamba/envs/del2/lib/python3.12/site-packages/scipy/sparse/_base.py:251, in _spbase.__iter__(self)
    249 def __iter__(self):
    250     for r in range(self.shape[0]):
--> 251         yield self[r, :]

File ~/micromamba/envs/del2/lib/python3.12/site-packages/scipy/sparse/_index.py:53, in IndexMixin.__getitem__(self, key)
     51     return self._get_intXint(row, col)
     52 elif isinstance(col, slice):
---> 53     self._raise_on_1d_array_slice()
     54     return self._get_intXslice(row, col)
     55 elif col.ndim == 1:

File ~/micromamba/envs/del2/lib/python3.12/site-packages/scipy/sparse/_index.py:40, in IndexMixin._raise_on_1d_array_slice(self)
     37 from scipy.sparse import sparray
     39 if isinstance(self, sparray):
---> 40     raise NotImplementedError(
     41         'We have not yet implemented 1D sparse slices; '
     42         'please index using explicit indices, e.g. `x[:, [0]]`'
     43     )

NotImplementedError: We have not yet implemented 1D sparse slices; please index using explicit indices, e.g. `x[:, [0]]`
```


### SciPy/NumPy/Python version and system information

```shell
System:
    python: 3.12.1 | packaged by conda-forge | (main, Dec 23 2023, 08:03:24) [GCC 12.3.0]
executable: /home/lesteve/micromamba/envs/del2/bin/python3.12
   machine: Linux-6.7.4-arch1-1-x86_64-with-glibc2.39

Python dependencies:
      sklearn: 1.3.1
          pip: 24.0
   setuptools: 69.0.3
        numpy: 1.26.4
        scipy: 1.12.0
       Cython: None
       pandas: None
   matplotlib: None
       joblib: 1.3.2
threadpoolctl: 3.2.0

Built with OpenMP: True

threadpoolctl info:
       user_api: blas
   internal_api: openblas
    num_threads: 8
         prefix: libopenblas
       filepath: /home/lesteve/micromamba/envs/del2/lib/libopenblasp-r0.3.25.so
        version: 0.3.25
threading_layer: pthreads
   architecture: SkylakeX

       user_api: openmp
   internal_api: openmp
    num_threads: 8
         prefix: libgomp
       filepath: /home/lesteve/micromamba/envs/del2/lib/libgomp.so.1.0.0
        version: None
```



Discussion:
It doesn't just raise when trying to stack on a dok format. This fails as well:

```
import scipy.sparse as sp
import numpy as np

X_dense = np.array([[1, 0, 0, 2], [0, 4, 1, 0], [0, 0, 5, 0]])
X_sparse = sp.dok_array(X_dense)

Y_sparse = sp.lil_array((3, 1))

sp.hstack((X_sparse, Y_sparse), format="lil")
```

@dschult FYI. One major issue here is that the `np.asarray(dok, dtype='object')` call appears to be trying to iterate over rows of the sparse array.
Thanks for your answer, if there is some kind of work-around it would be great to know in the context of scikit-learn where we just discovered this, see https://github.com/scikit-learn/scikit-learn/pull/27576#issuecomment-1935949655 for more details.
The "easy" workaround is to convert to `coo_array`:
- the output of `hstack` with `dok` (or `lil`) is a `coo` format anyway, and 
- even with the conversion converting to coo first is faster than `hstack`-ing the `dok_array`s.
- I haven't checked the memory usage required... but releasing the converted memory quickly should help.
```python
sp.sparse.hstack([(Acoo := Adok.tocoo()), Acoo])
del Acoo  # or not if it won't be sitting around very long anyway
```

For the longer term solution, I need to figure out what makes `np.asarray` (and `np.array`) decide that the input should just be treated as a python object instead of tryig to convert it.  
All `sp.sparse array/matrix` classes except `dok_array` are handled as python objects. And `dok_array` would work if we subclassed `dict` even though that would provide no extra functionality. NetworkX Graphs are iterated over just as `dok_array`s are, instead of being treated as python objects (which is what we want in this situation).

So -- we are working on a solution. The short term fix is to convert to `coo` before `hstack`ing. That is faster anyway, but I haven't checked memory impacts.
The function `np.asarray` treats sparse arrays as python objects instead of arrays. Scipy.sparse uses this trait in functions like `sp.sparse.hstack` to get the pattern of the arrays to be stacked without inspection of the inner values of each sparse array.  

Two relevant traits that `np.asarray` looks for are:
1) Does object have a `__len__` method? (is it a sequence with a length)
2) Is the object NOT a subclass of `dict`?
Either of these two traits will make the sparse arrays no longer treated as python objects in `np.asarray`, "breaking" our construction functions.

The changes in #18929 broke this behavior for `dok_array` by making it no longer a subclass of `dict`. DOK is the only format with a `__len__` method, so it must subclass `dict` for the construction functions to work. Instead numpy iterates through the sparse array building a dense version and we don't want that.

Potential long term fixes:
- remove the `__len__` method from `dok_array`
- make dok_array a subclass of dict even though all the relevant methods are overwritten
- rewrite the construction functions to avoid using numpy arrays with python sparse arrays as objects.

I propose we make dok_array a subclass of `dict`. While the `__len__` function is of questionable value (it returns `nnz`), it matches the dict-of-keys philosophy. Adding `dict` as a superclass for `csr_array` has little impact. Rewriting the construction functions to avoid `asarray` can be done -- but is definitely more work with possible side effects.

Resolving patch:
diff --git a/scipy/sparse/_dok.py b/scipy/sparse/_dok.py
--- a/scipy/sparse/_dok.py
+++ b/scipy/sparse/_dok.py
@@ -14,7 +14,7 @@
                        upcast, upcast_scalar, check_shape)
 
 
-class _dok_base(_spbase, IndexMixin):
+class _dok_base(_spbase, IndexMixin, dict):
     _format = 'dok'
 
     def __init__(self, arg1, shape=None, dtype=None, copy=False):
@@ -466,7 +466,7 @@ class dok_array(_dok_base, sparray):
     """
 
 
-class dok_matrix(spmatrix, _dok_base, dict):
+class dok_matrix(spmatrix, _dok_base):
     """
     Dictionary Of Keys based sparse matrix.
 


Test patch:
diff --git a/scipy/sparse/tests/test_construct.py b/scipy/sparse/tests/test_construct.py
--- a/scipy/sparse/tests/test_construct.py
+++ b/scipy/sparse/tests/test_construct.py
@@ -400,6 +400,8 @@ def test_vstack(self, coo_cls):
         assert_equal(construct.vstack([A, B], dtype=np.float32).dtype,
                      np.float32)
 
+        assert_equal(construct.vstack([A.todok(), B.todok()]).toarray(), expected)
+
         assert_equal(construct.vstack([A.tocsr(), B.tocsr()]).toarray(),
                      expected)
         result = construct.vstack([A.tocsr(), B.tocsr()],
@@ -425,7 +427,7 @@ def test_vstack_matrix_or_array(self):
         assert isinstance(construct.vstack([coo_matrix(A), coo_matrix(B)]), spmatrix)
 
     @pytest.mark.parametrize("coo_cls", [coo_matrix, coo_array])
-    def test_hstack(self,coo_cls):
+    def test_hstack(self, coo_cls):
         A = coo_cls([[1,2],[3,4]])
         B = coo_cls([[5],[6]])
 
@@ -434,6 +436,9 @@ def test_hstack(self,coo_cls):
         assert_equal(construct.hstack([A, B]).toarray(), expected)
         assert_equal(construct.hstack([A, B], dtype=np.float32).dtype,
                      np.float32)
+
+        assert_equal(construct.hstack([A.todok(), B.todok()]).toarray(), expected)
+
         assert_equal(construct.hstack([A.tocsc(), B.tocsc()]).toarray(),
                      expected)
         assert_equal(construct.hstack([A.tocsc(), B.tocsc()],