Repository: scipy/scipy

Issue:
BUG: sparse matrix creation in 1.13 with indices not summing entries any more
### Describe your issue.

The behaviour of sparse matrix (CSC, CSR) creation has changed in 1.13 breaking our code. Multiple data entries with the same indices are not summed up any longer but produce duplicate entries. I did not find this in the release notes. Maybe it is wanted this way, but the documentation explicitly states that conversion from COO to CSC sums up duplicate entries, so why drop this feature during creation?

### Reproducing Code Example

```python
from scipy import sparse
import numpy as np

dat = np.ones(3)
i = np.array((0,1,1))
j = np.array((1,0,0))

mat = sparse.csc_matrix((dat, (i, j)), shape=(2, 2))

# produces in 1.12:
print(mat)
  (1, 0)	2.0
  (0, 1)	1.0

# but in 1.13:
print(mat)
  (1, 0)	1.0
  (1, 0)	1.0
  (0, 1)	1.0

# converting it eg from COO to CSC will sum up the entries, but why not during creation?
mat2 = mat.tocoo()
>>> print(mat2)
  (1, 0)	1.0
  (1, 0)	1.0
  (0, 1)	1.0
>>> mat3 = mat2.tocsc()
>>> print(mat3)
  (1, 0)	2.0
  (0, 1)	1.0
```


### Error message

```shell
No error message, just different sparse matrices at creation. I believe this is a bug, as the conversion, e.g. from COO format actually sums the entries as described here:
https://docs.scipy.org/doc/scipy/reference/sparse.html#example-2
which is important for FEM matrices or adjacency matrices of a mesh.
```


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

```shell
1.13.0 1.26.4 sys.version_info(major=3, minor=10, micro=11, releaselevel='final', serial=0)
{
  "Compilers": {
    "c": {
      "name": "clang",
      "linker": "ld64",
      "version": "15.0.0",
      "commands": "cc"
    },
    "cython": {
      "name": "cython",
      "linker": "cython",
      "version": "3.0.10",
      "commands": "cython"
    },
    "c++": {
      "name": "clang",
      "linker": "ld64",
      "version": "15.0.0",
      "commands": "c++"
    },
    "fortran": {
      "name": "gcc",
      "linker": "ld64",
      "version": "13.2.0",
      "commands": "gfortran"
    },
    "pythran": {
      "version": "0.15.0",
      "include directory": "../../../../../../private/var/folders/l_/53d72k7j4zl9kj8fzncsd8hw0000gn/T/pip-build-env-b8vbzmu2/overlay/lib/python3.10/site-packages/pythran"
    }
  },
  "Machine Information": {
    "host": {
      "cpu": "aarch64",
      "family": "aarch64",
      "endian": "little",
      "system": "darwin"
    },
    "build": {
      "cpu": "aarch64",
      "family": "aarch64",
      "endian": "little",
      "system": "darwin"
    },
    "cross-compiled": false
  },
  "Build Dependencies": {
    "blas": {
      "name": "openblas",
      "found": true,
      "version": "0.3.26.dev",
      "detection method": "pkgconfig",
      "include directory": "/opt/arm64-builds/include",
      "lib directory": "/opt/arm64-builds/lib",
      "openblas configuration": "USE_64BITINT=0 DYNAMIC_ARCH=1 DYNAMIC_OLDER= NO_CBLAS= NO_LAPACK= NO_LAPACKE= NO_AFFINITY=1 USE_OPENMP= SANDYBRIDGE MAX_THREADS=64",
      "pc file directory": "/opt/arm64-builds/lib/pkgconfig"
    },
    "lapack": {
      "name": "openblas",
      "found": true,
      "version": "0.3.26.dev",
      "detection method": "pkgconfig",
      "include directory": "/opt/arm64-builds/include",
      "lib directory": "/opt/arm64-builds/lib",
      "openblas configuration": "USE_64BITINT=0 DYNAMIC_ARCH=1 DYNAMIC_OLDER= NO_CBLAS= NO_LAPACK= NO_LAPACKE= NO_AFFINITY=1 USE_OPENMP= SANDYBRIDGE MAX_THREADS=64",
      "pc file directory": "/opt/arm64-builds/lib/pkgconfig"
    },
    "pybind11": {
      "name": "pybind11",
      "version": "2.12.0",
      "detection method": "config-tool",
      "include directory": "unknown"
    }
  },
  "Python Information": {
    "path": "/private/var/folders/l_/53d72k7j4zl9kj8fzncsd8hw0000gn/T/cibw-run-09g5pzn4/cp310-macosx_arm64/build/venv/bin/python",
    "version": "3.10"
  }
}
```



Discussion:
For the first part of the reproducer, on latest `main` at time of writing, I see this alongside NumPy `2.0.0rc1`:

```python
  (np.int32(1), np.int32(0))	1.0
  (np.int32(1), np.int32(0))	1.0
  (np.int32(0), np.int32(1))	1.0
```

`sparse` folks can let me know if they need additional backports for `1.13.1` if it needs a fix.
This does indeed need a fix. It's a bug introduced in #19962 while trying to avoid multiple conversions between formats. 
It "should be" a quick fix. 

And, yes -- the fix should get additional backport for 1.13.1
   
Thank you @m-reuter for reporting it!  Thank you @tylerjereddy for the backports!

Resolving patch:
diff --git a/scipy/sparse/_compressed.py b/scipy/sparse/_compressed.py
--- a/scipy/sparse/_compressed.py
+++ b/scipy/sparse/_compressed.py
@@ -55,6 +55,7 @@ def __init__(self, arg1, shape=None, dtype=None, copy=False):
                     coo = self._coo_container(arg1, shape=shape, dtype=dtype)
                     arrays = coo._coo_to_compressed(self._swap)
                     self.indptr, self.indices, self.data, self._shape = arrays
+                    self.sum_duplicates()
                 elif len(arg1) == 3:
                     # (data, indices, indptr) format
                     (data, indices, indptr) = arg1


Test patch:
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
@@ -3802,6 +3802,10 @@ def test_constructor4(self):
         dense = array([[2**63 + 1, 0], [0, 1]], dtype=np.uint64)
         assert_array_equal(dense, csr.toarray())
 
+        # with duplicates (should sum the duplicates)
+        csr = csr_matrix(([1,1,1,1], ([0,2,2,0], [0,1,1,0])))
+        assert csr.nnz == 2
+
     def test_constructor5(self):
         # infer dimensions from arrays
         indptr = array([0,1,3,3])
@@ -4042,6 +4046,10 @@ def test_constructor4(self):
         csc = csc_matrix((data,ij),(4,3))
         assert_array_equal(arange(12).reshape(4, 3), csc.toarray())
 
+        # with duplicates (should sum the duplicates)
+        csc = csc_matrix(([1,1,1,1], ([0,2,2,0], [0,1,1,0])))
+        assert csc.nnz == 2
+
     def test_constructor5(self):
         # infer dimensions from arrays
         indptr = array([0,1,3,3])
@@ -4456,6 +4464,13 @@ def test_todok_duplicates(self):
         dok = coo.todok()
         assert_array_equal(dok.toarray(), coo.toarray())
 
+    def test_tocompressed_duplicates(self):
+        coo = coo_matrix(([1,1,1,1], ([0,2,2,0], [0,1,1,0])))
+        csr = coo.tocsr()
+        assert_equal(csr.nnz + 2, coo.nnz)
+        csc = coo.tocsc()
+        assert_equal(csc.nnz + 2, coo.nnz)
+
     def test_eliminate_zeros(self):
         data = array([1, 0, 0, 0, 2, 0, 3, 0])
         row = array([0, 0, 0, 1, 1, 1, 1, 1])