Repository: scipy/scipy

Issue:
BUG: `spdiags`, `eye`, `identity` corner case when offset greater than array shape
### Describe your issue.

The `spdiags` creation function allows the diagonal offsets to be greater than the matrix shape, which results in an empty array in `dia` format with a negative number of non-zero values:

```python
>>> m = n = 3  # Square, 3x3
>>> k = -5  # diagonal offset
>>> data = np.ones(m)
>>> a = sp.sparse.spdiags(data, diags=k, m=m, n=n)
>>> a
<3x3 sparse matrix of type '<class 'numpy.float64'>'
        with -2 stored elements (1 diagonals) in DIAgonal format>
>>> a.toarray()  # 3x3 array of zeros
array([[0., 0., 0.],
       [0., 0., 0.],
       [0., 0., 0.]])
```

Both `identity` and `eye` are implemented using `spdiags` (at least for some formats), which means that this behavior extends to those functions as well.

This behavior is not strictly incorrect, but is likely not what users want nor expect. `diags`, which the documentation notes is preferred over `spdiags`, explicitly validates that the diagonal offset is less than the minimum dimension of the requested shape:

```python
>>> sp.sparse.diags([data], offsets=[k], shape=(m, n))
Traceback (most recent call last)
   ...
ValueError: Offset -5 (index 0) out of bounds
```

### Proposed change

I propose to modify `eye` and `identity` to use `diags` instead of `spdiags`. This will change behavior so that `eye` and `identity` will raise when the diagonal offset is greater than the shape.

### Reproducing Code Example

```python
See above
```


### Error message

```shell
N/A
```


### SciPy/NumPy/Python version and system information

```shell
1.11.0.dev0+2204.675b17e 1.24.3 sys.version_info(major=3, minor=11, micro=3, releaselevel='final', serial=0)
Build Dependencies:
  blas:
    detection method: pkgconfig
    found: true
    include directory: /usr/include
    lib directory: /usr/lib
    name: openblas
    openblas configuration: USE_64BITINT= DYNAMIC_ARCH= DYNAMIC_OLDER= NO_CBLAS= NO_LAPACK=0
      NO_LAPACKE= NO_AFFINITY=1 USE_OPENMP=1 HASWELL MAX_THREADS=8
    pc file directory: /usr/lib/pkgconfig
    version: 0.3.21
  lapack:
    detection method: pkgconfig
    found: true
    include directory: /usr/include
    lib directory: /usr/lib
    name: openblas
    openblas configuration: USE_64BITINT= DYNAMIC_ARCH= DYNAMIC_OLDER= NO_CBLAS= NO_LAPACK=0
      NO_LAPACKE= NO_AFFINITY=1 USE_OPENMP=1 HASWELL MAX_THREADS=8
    pc file directory: /usr/lib/pkgconfig
    version: 0.3.21
  pybind11:
    detection method: config-tool
    include directory: unknown
    name: pybind11
    version: 2.10.4
Compilers:
  c:
    commands: ccache, cc
    linker: ld.bfd
    name: gcc
    version: 13.1.1
  c++:
    commands: ccache, c++
    linker: ld.bfd
    name: gcc
    version: 13.1.1
  cython:
    commands: cython
    linker: cython
    name: cython
    version: 0.29.35
  fortran:
    commands: gfortran
    linker: ld.bfd
    name: gcc
    version: 13.1.1
  pythran:
    include directory: ../../../.virtualenvs/sp-dev/lib/python3.11/site-packages/pythran
    version: 0.13.1
Machine Information:
  build:
    cpu: x86_64
    endian: little
    family: x86_64
    system: linux
  cross-compiled: false
  host:
    cpu: x86_64
    endian: little
    family: x86_64
    system: linux
Python Information:
  path: /home/ross/.virtualenvs/sp-dev/bin/python
  version: '3.11'
```



Discussion:
+1, this would be a nice improvement.

Aside: It's unfortunate that the DIA `nnz` semantics don't really match the rest of the formats, where `nnz` reflects the number of stored entries (including duplicates). If you're using `nnz` to keep track of the storage cost of a sparse array/matrix, then the DIA result isn't what you want.

Resolving patch:
diff --git a/scipy/sparse/_construct.py b/scipy/sparse/_construct.py
--- a/scipy/sparse/_construct.py
+++ b/scipy/sparse/_construct.py
@@ -348,8 +348,8 @@ def eye(m, n=None, k=0, dtype=float, format=None):
             data = np.ones(n, dtype=dtype)
             return coo_matrix((data, (row, col)), (n, n))
 
-    diags = np.ones((1, max(0, min(m + k, n))), dtype=dtype)
-    return spdiags(diags, k, m, n).asformat(format)
+    data = np.ones((1, max(0, min(m + k, n))), dtype=dtype)
+    return diags(data, offsets=[k], shape=(m, n), dtype=dtype).asformat(format)
 
 
 def kron(A, B, format=None):


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
@@ -248,9 +248,29 @@ def test_eye(self):
         for m in [3, 5]:
             for n in [3, 5]:
                 for k in range(-5,6):
-                    assert_equal(construct.eye(m, n, k=k).toarray(), np.eye(m, n, k=k))
-                    if m == n:
-                        assert_equal(construct.eye(m, k=k).toarray(), np.eye(m, n, k=k))
+                    # scipy.sparse.eye deviates from np.eye here. np.eye will
+                    # create arrays of all 0's when the diagonal offset is
+                    # greater than the size of the array. For sparse arrays
+                    # this makes less sense, especially as it results in dia
+                    # arrays with negative diagonals. Therefore sp.sparse.eye
+                    # validates that diagonal offsets fall within the shape of
+                    # the array. See gh-18555.
+                    if (k > 0 and k > n) or (k < 0 and abs(k) > m):
+                        with pytest.raises(
+                            ValueError, match="Offset.*out of bounds"
+                        ):
+                            construct.eye(m, n, k=k)
+
+                    else:
+                        assert_equal(
+                            construct.eye(m, n, k=k).toarray(),
+                            np.eye(m, n, k=k)
+                        )
+                        if m == n:
+                            assert_equal(
+                                construct.eye(m, k=k).toarray(),
+                                np.eye(m, n, k=k)
+                            )
 
     def test_eye_one(self):
         assert_equal(construct.eye(1).toarray(), [[1]])