Repository: cupy/cupy

Issue:
Implement boolean column subsetting for `cupyx.scipy.sparse.csr.csr_matrix`
E.g. `xs[:, subset]`, where subset is e.g. `np.array([True, False, True, False, True])` and `xs` is a `cupyx.scipy.sparse.csr.csr_matrix`. Note that row subsetting does not work in this case.

This came up when trying to run Scanpy with CuPy and Dask: https://github.com/theislab/scanpy/issues/921.


Discussion:
cc @mrocklin @pentschev

Resolving patch:
diff --git a/cupyx/scipy/sparse/_index.py b/cupyx/scipy/sparse/_index.py
--- a/cupyx/scipy/sparse/_index.py
+++ b/cupyx/scipy/sparse/_index.py
@@ -94,18 +94,15 @@ def _get_csr_submatrix_minor_axis(Ax, Aj, Ap, start, stop):
 
 def _csr_row_index(Ax, Aj, Ap, rows):
     """Populate indices and data arrays from the given row index
-
     Args:
         Ax (cupy.ndarray): data array from input sparse matrix
         Aj (cupy.ndarray): indices array from input sparse matrix
         Ap (cupy.ndarray): indptr array from input sparse matrix
         rows (cupy.ndarray): index array of rows to populate
-
     Returns:
         Bx (cupy.ndarray): data array of output sparse matrix
         Bj (cupy.ndarray): indices array of output sparse matrix
         Bp (cupy.ndarray): indptr array for output sparse matrix
-
     """
     row_nnz = cupy.diff(Ap)
     Bp = cupy.empty(rows.size + 1, dtype=Ap.dtype)
@@ -113,6 +110,13 @@ def _csr_row_index(Ax, Aj, Ap, rows):
     cupy.cumsum(row_nnz[rows], out=Bp[1:])
     nnz = int(Bp[-1])
 
+    out_rows = _csr_indptr_to_coo_rows(nnz, Bp)
+
+    Bj, Bx = _csr_row_index_ker(out_rows, rows, Ap, Aj, Ax, Bp)
+    return Bx, Bj, Bp
+
+
+def _csr_indptr_to_coo_rows(nnz, Bp):
     out_rows = cupy.empty(nnz, dtype=numpy.int32)
 
     # Build a COO row array from output CSR indptr.
@@ -123,13 +127,149 @@ def _csr_row_index(Ax, Aj, Ap, rows):
         handle, Bp.data.ptr, nnz, Bp.size-1, out_rows.data.ptr,
         cusparse.CUSPARSE_INDEX_BASE_ZERO)
 
-    Bj, Bx = _csr_row_index_ker(out_rows, rows, Ap, Aj, Ax, Bp)
-    return Bx, Bj, Bp
+    return out_rows
+
+
+def _select_last_indices(i, j, x, idx_dtype):
+    """Find the unique indices for each row and keep only the last"""
+    i = cupy.asarray(i, dtype=idx_dtype)
+    j = cupy.asarray(j, dtype=idx_dtype)
+
+    stacked = cupy.stack([j, i])
+    order = cupy.lexsort(stacked).astype(idx_dtype)
+
+    indptr_inserts = i[order]
+    indices_inserts = j[order]
+    data_inserts = x[order]
+
+    mask = cupy.ones(indptr_inserts.size, dtype='bool')
+    _unique_mask_kern(indptr_inserts, indices_inserts, order, mask,
+                      size=indptr_inserts.size-1)
+
+    return indptr_inserts[mask], indices_inserts[mask], data_inserts[mask]
+
+
+_insert_many_populate_arrays = core.ElementwiseKernel(
+    '''raw I insert_indices, raw T insert_values, raw I insertion_indptr,
+        raw I Ap, raw I Aj, raw T Ax, raw I Bp''',
+    'raw I Bj, raw T Bx', '''
+
+        const I input_row_start = Ap[i];
+        const I input_row_end = Ap[i+1];
+        const I input_count = input_row_end - input_row_start;
+
+        const I insert_row_start = insertion_indptr[i];
+        const I insert_row_end = insertion_indptr[i+1];
+        const I insert_count = insert_row_end - insert_row_start;
+
+        I input_offset = 0;
+        I insert_offset = 0;
+
+        I output_n = Bp[i];
+
+        I cur_existing_index = -1;
+        T cur_existing_value = -1;
+
+        I cur_insert_index = -1;
+        T cur_insert_value = -1;
+
+        if(input_offset < input_count) {
+            cur_existing_index = Aj[input_row_start+input_offset];
+            cur_existing_value = Ax[input_row_start+input_offset];
+        }
+
+        if(insert_offset < insert_count) {
+            cur_insert_index = insert_indices[insert_row_start+insert_offset];
+            cur_insert_value = insert_values[insert_row_start+insert_offset];
+        }
+
+
+        for(I jj = 0; jj < input_count + insert_count; jj++) {
+
+            // if we have both available, use the lowest one.
+            if(input_offset < input_count &&
+               insert_offset < insert_count) {
+
+                if(cur_existing_index < cur_insert_index) {
+                    Bj[output_n] = cur_existing_index;
+                    Bx[output_n] = cur_existing_value;
+
+                    ++input_offset;
+
+                    if(input_offset < input_count) {
+                        cur_existing_index = Aj[input_row_start+input_offset];
+                        cur_existing_value = Ax[input_row_start+input_offset];
+                    }
+
+
+                } else {
+                    Bj[output_n] = cur_insert_index;
+                    Bx[output_n] = cur_insert_value;
+
+                    ++insert_offset;
+                    if(insert_offset < insert_count) {
+                        cur_insert_index =
+                            insert_indices[insert_row_start+insert_offset];
+                        cur_insert_value =
+                            insert_values[insert_row_start+insert_offset];
+                    }
+                }
+
+            } else if(input_offset < input_count) {
+                Bj[output_n] = cur_existing_index;
+                Bx[output_n] = cur_existing_value;
+
+                ++input_offset;
+                if(input_offset < input_count) {
+                    cur_existing_index = Aj[input_row_start+input_offset];
+                    cur_existing_value = Ax[input_row_start+input_offset];
+                }
+
+            } else {
+                    Bj[output_n] = cur_insert_index;
+                    Bx[output_n] = cur_insert_value;
+
+                    ++insert_offset;
+                    if(insert_offset < insert_count) {
+                        cur_insert_index =
+                            insert_indices[insert_row_start+insert_offset];
+                        cur_insert_value =
+                            insert_values[insert_row_start+insert_offset];
+                    }
+            }
+
+            output_n++;
+        }
+    ''', 'csr_copy_existing_indices_kern', no_return=True)
+
+
+# Create a filter mask based on the lowest value of order
+_unique_mask_kern = core.ElementwiseKernel(
+    '''raw I rows, raw I cols, raw I order''',
+    '''raw bool mask''',
+    """
+    I cur_row = rows[i];
+    I next_row = rows[i+1];
+
+    I cur_col = cols[i];
+    I next_col = cols[i+1];
+
+    I cur_order = order[i];
+    I next_order = order[i+1];
+
+    if(cur_row == next_row && cur_col == next_col) {
+        if(cur_order < next_order)
+            mask[i] = false;
+        else
+            mask[i+1] = false;
+    }
+    """, no_return=True
+)
 
 
 def _csr_sample_values(n_row, n_col,
                        Ap, Aj, Ax,
-                       Bi, Bj):
+                       Bi, Bj, not_found_val=0):
     """Populate data array for a set of rows and columns
     Args
         n_row : total number of rows in input array
@@ -146,28 +286,31 @@ def _csr_sample_values(n_row, n_col,
     Bi[Bi < 0] += n_row
     Bj[Bj < 0] += n_col
 
-    Bx = cupy.empty(Bi.size, dtype=Ax.dtype)
-    _csr_sample_values_kern(n_row, n_col,
-                            Ap, Aj, Ax,
-                            Bi, Bj, Bx, size=Bi.size)
-
-    return Bx
+    return _csr_sample_values_kern(n_row, n_col,
+                                   Ap, Aj, Ax,
+                                   Bi, Bj,
+                                   not_found_val,
+                                   size=Bi.size)
 
 
 _csr_sample_values_kern = core.ElementwiseKernel(
-    '''I n_row, I n_col, raw I Ap, raw I Aj, raw T Ax, raw I Bi, raw I Bj''',
+    '''I n_row, I n_col, raw I Ap, raw I Aj, raw T Ax,
+    raw I Bi, raw I Bj, I not_found_val''',
     'raw T Bx', '''
     const I j = Bi[i]; // sample row
     const I k = Bj[i]; // sample column
     const I row_start = Ap[j];
     const I row_end   = Ap[j+1];
     T x = 0;
+    bool val_found = false;
     for(I jj = row_start; jj < row_end; jj++) {
-        if (Aj[jj] == k)
+        if (Aj[jj] == k) {
             x += Ax[jj];
+            val_found = true;
+        }
     }
-    Bx[i] = x;
-''', 'csr_sample_values_kern', no_return=True)
+    Bx[i] = val_found ? x : not_found_val;
+''', 'csr_sample_values_kern')
 
 
 class IndexMixin(object):
@@ -227,6 +370,57 @@ def __getitem__(self, key):
             return self.__class__(cupy.atleast_2d(row).shape, dtype=self.dtype)
         return self._get_arrayXarray(row, col)
 
+    def __setitem__(self, key, x):
+        row, col = self._parse_indices(key)
+
+        if isinstance(row, _int_scalar_types) and\
+                isinstance(col, _int_scalar_types):
+            x = cupy.asarray(x, dtype=self.dtype)
+            if x.size != 1:
+                raise ValueError('Trying to assign a sequence to an item')
+            self._set_intXint(row, col, x.flat[0])
+            return
+
+        if isinstance(row, slice):
+            row = cupy.arange(*row.indices(self.shape[0]))[:, None]
+        else:
+            row = cupy.atleast_1d(row)
+
+        if isinstance(col, slice):
+            col = cupy.arange(*col.indices(self.shape[1]))[None, :]
+            if row.ndim == 1:
+                row = row[:, None]
+        else:
+            col = cupy.atleast_1d(col)
+
+        i, j = cupy.broadcast_arrays(row, col)
+        if i.shape != j.shape:
+            raise IndexError('number of row and column indices differ')
+
+        if isspmatrix(x):
+            if i.ndim == 1:
+                # Inner indexing, so treat them like row vectors.
+                i = i[None]
+                j = j[None]
+            broadcast_row = x.shape[0] == 1 and i.shape[0] != 1
+            broadcast_col = x.shape[1] == 1 and i.shape[1] != 1
+            if not ((broadcast_row or x.shape[0] == i.shape[0]) and
+                    (broadcast_col or x.shape[1] == i.shape[1])):
+                raise ValueError('shape mismatch in assignment')
+            if x.size == 0:
+                return
+            x = x.tocoo(copy=True)
+            x.sum_duplicates()
+            self._set_arrayXarray_sparse(i, j, x)
+        else:
+            # Make x and i into the same shape
+            x = cupy.asarray(x, dtype=self.dtype)
+            x, _ = cupy.broadcast_arrays(x, i)
+            if x.size == 0:
+                return
+            x = x.reshape(i.shape)
+            self._set_arrayXarray(i, j, x)
+
     def _is_scalar(self, index):
         if isinstance(index, (cupy.ndarray, numpy.ndarray)) and \
                 index.ndim == 0 and index.size == 1:
@@ -237,15 +431,15 @@ def _parse_indices(self, key):
         M, N = self.shape
         row, col = _unpack_index(key)
 
-        # Scipy calls sputils.isintlike() rather than
-        # isinstance(x, _int_scalar_types). Comparing directly to int
-        # here to minimize the impact of nested exception catching
-
         if self._is_scalar(row):
             row = row.item()
         if self._is_scalar(col):
             col = col.item()
 
+        # Scipy calls sputils.isintlike() rather than
+        # isinstance(x, _int_scalar_types). Comparing directly to int
+        # here to minimize the impact of nested exception catching
+
         if isinstance(row, _int_scalar_types):
             row = _normalize_index(row, M, 'row')
         elif not isinstance(row, slice):
diff --git a/cupyx/scipy/sparse/compressed.py b/cupyx/scipy/sparse/compressed.py
--- a/cupyx/scipy/sparse/compressed.py
+++ b/cupyx/scipy/sparse/compressed.py
@@ -1,16 +1,22 @@
 import numpy
+
 try:
     import scipy.sparse
     scipy_available = True
 except ImportError:
     scipy_available = False
 
+import warnings
+
 import cupy
+import cupyx
+
 from cupy import core
 from cupy._creation import basic
 from cupy import cusparse
 from cupyx.scipy.sparse import base
 from cupyx.scipy.sparse import data as sparse_data
+from cupyx.scipy.sparse import sputils
 from cupyx.scipy.sparse import _util
 
 from cupyx.scipy.sparse import _index
@@ -496,7 +502,7 @@ def _get_sliceXslice(self, row, col):
         copy = major.step in (1, None)
         return self._major_slice(major)._minor_slice(minor, copy=copy)
 
-    def _get_arrayXarray(self, row, col):
+    def _get_arrayXarray(self, row, col, not_found_val=0):
         # inner indexing
         idx_dtype = self.indices.dtype
         M, N = self._swap(*self.shape)
@@ -506,7 +512,8 @@ def _get_arrayXarray(self, row, col):
 
         val = _index._csr_sample_values(
             M, N, self.indptr, self.indices, self.data,
-            major.ravel(), minor.ravel())
+            major.ravel(), minor.ravel(),
+            not_found_val)
 
         if major.ndim == 1:
             # Scipy returns `matrix` here
@@ -590,6 +597,181 @@ def _minor_slice(self, idx, copy=False):
         cols = cupy.arange(start, stop, step, dtype=self.indices.dtype)
         return self._minor_index_fancy(cols)
 
+    def _set_intXint(self, row, col, x):
+        i, j = self._swap(row, col)
+        self._set_many(i, j, x)
+
+    def _set_arrayXarray(self, row, col, x):
+        i, j = self._swap(row, col)
+        self._set_many(i, j, x)
+
+    def _set_arrayXarray_sparse(self, row, col, x):
+        # clear entries that will be overwritten
+        self._zero_many(*self._swap(row, col))
+
+        M, N = row.shape  # matches col.shape
+        broadcast_row = M != 1 and x.shape[0] == 1
+        broadcast_col = N != 1 and x.shape[1] == 1
+        r, c = x.row, x.col
+        x = cupy.asarray(x.data, dtype=self.dtype)
+        if broadcast_row:
+            r = cupy.repeat(cupy.arange(M), r.size)
+            c = cupy.tile(c, M)
+            x = cupy.tile(x, M)
+        if broadcast_col:
+            r = cupy.repeat(r, N)
+            c = cupy.tile(cupy.arange(N), c.size)
+            x = cupy.repeat(x, N)
+        # only assign entries in the new sparsity structure
+        i, j = self._swap(row[r, c], col[r, c])
+        self._set_many(i, j, x)
+
+    def _prepare_indices(self, i, j):
+        M, N = self._swap(*self.shape)
+
+        def check_bounds(indices, bound):
+            idx = indices.max()
+            if idx >= bound:
+                raise IndexError('index (%d) out of range (>= %d)' %
+                                 (idx, bound))
+            idx = indices.min()
+            if idx < -bound:
+                raise IndexError('index (%d) out of range (< -%d)' %
+                                 (idx, bound))
+
+        i = cupy.array(i, dtype=self.indptr.dtype,
+                       copy=True, ndmin=1).ravel()
+        j = cupy.array(j, dtype=self.indices.dtype,
+                       copy=True, ndmin=1).ravel()
+        check_bounds(i, M)
+        check_bounds(j, N)
+        return i, j, M, N
+
+    def _set_many(self, i, j, x):
+        """Sets value at each (i, j) to x
+        Here (i,j) index major and minor respectively, and must not contain
+        duplicate entries.
+        """
+        i, j, M, N = self._prepare_indices(i, j)
+        x = cupy.array(x, dtype=self.dtype, copy=True, ndmin=1).ravel()
+
+        new_sp = cupyx.scipy.sparse.csr_matrix(
+            (cupy.arange(self.nnz, dtype=cupy.float32),
+             self.indices, self.indptr), shape=(M, N))
+
+        offsets = new_sp._get_arrayXarray(
+            i, j, not_found_val=-1).astype(cupy.int32).ravel()
+
+        if -1 not in offsets:
+            # only affects existing non-zero cells
+            self.data[offsets] = x
+            return
+
+        else:
+            warnings.warn('Changing the sparsity structure of a '
+                          '{}_matrix is expensive.'
+                          ' lil_matrix is more efficient.'.format(self.format))
+            # replace where possible
+            mask = offsets > -1
+            self.data[offsets[mask]] = x[mask]
+            # only insertions remain
+            mask = ~mask
+            i = i[mask]
+            i[i < 0] += M
+            j = j[mask]
+            j[j < 0] += N
+            self._insert_many(i, j, x[mask])
+
+    def _zero_many(self, i, j):
+        """Sets value at each (i, j) to zero, preserving sparsity structure.
+        Here (i,j) index major and minor respectively.
+        """
+        i, j, M, N = self._prepare_indices(i, j)
+
+        new_sp = cupyx.scipy.sparse.csr_matrix(
+            (cupy.arange(self.nnz, dtype=cupy.float32),
+             self.indices, self.indptr), shape=(M, N))
+
+        offsets = new_sp._get_arrayXarray(
+            i, j, not_found_val=-1).astype(cupy.int32).ravel()
+
+        # only assign zeros to the existing sparsity structure
+        self.data[offsets[offsets > -1]] = 0
+
+    def _perform_insert(self, indices_inserts, data_inserts,
+                        rows, row_counts, idx_dtype):
+        """Insert new elements into current sparse matrix in sorted order"""
+        indptr_diff = cupy.diff(self.indptr)
+        indptr_diff[rows] += row_counts
+
+        new_indptr = cupy.empty(self.indptr.shape, dtype=idx_dtype)
+        new_indptr[0] = idx_dtype(0)
+        new_indptr[1:] = indptr_diff
+
+        # Build output arrays
+        cupy.cumsum(new_indptr, out=new_indptr)
+        out_nnz = int(new_indptr[-1])
+
+        new_indices = cupy.empty(out_nnz, dtype=idx_dtype)
+        new_data = cupy.empty(out_nnz, dtype=self.data.dtype)
+
+        # Build an indexed indptr that contains the offsets for each
+        # row but only for in i, j, and x.
+        new_indptr_lookup = cupy.zeros(new_indptr.size, dtype=idx_dtype)
+        new_indptr_lookup[1:][rows] = row_counts
+        cupy.cumsum(new_indptr_lookup, out=new_indptr_lookup)
+
+        _index._insert_many_populate_arrays(
+            indices_inserts, data_inserts, new_indptr_lookup,
+            self.indptr, self.indices, self.data, new_indptr, new_indices,
+            new_data, size=self.indptr.size-1)
+
+        self.indptr = new_indptr
+        self.indices = new_indices
+        self.data = new_data
+
+    def _insert_many(self, i, j, x):
+        """Inserts new nonzero at each (i, j) with value x
+        Here (i,j) index major and minor respectively.
+        i, j and x must be non-empty, 1d arrays.
+        Inserts each major group (e.g. all entries per row) at a time.
+        Maintains has_sorted_indices property.
+        Modifies i, j, x in place.
+        """
+
+        order = cupy.argsort(i)  # stable for duplicates
+        i = i.take(order)
+        j = j.take(order)
+        x = x.take(order)
+
+        # Update index data type
+
+        idx_dtype = sputils.get_index_dtype(
+            (self.indices, self.indptr), maxval=(
+                self.nnz + x.size))
+
+        self.indptr = self.indptr.astype(idx_dtype)
+        self.indices = self.indices.astype(idx_dtype)
+        self.data = self.data.astype(self.dtype)
+
+        indptr_inserts, indices_inserts, data_inserts = \
+            _index._select_last_indices(i, j, x, idx_dtype)
+
+        rows, ui_indptr = cupy.unique(indptr_inserts, return_index=True)
+
+        to_add = cupy.empty(ui_indptr.size+1, ui_indptr.dtype)
+        to_add[-1] = j.size
+        to_add[:-1] = ui_indptr
+        ui_indptr = to_add
+
+        # Compute the counts for each row in the insertion array
+        row_counts = cupy.zeros(ui_indptr.size-1, dtype=idx_dtype)
+        cupyx.scatter_add(
+            row_counts, cupy.searchsorted(rows, indptr_inserts), 1)
+
+        self._perform_insert(indices_inserts, data_inserts,
+                             rows, row_counts, idx_dtype)
+
     def __get_has_canonical_format(self):
         """Determine whether the matrix has sorted indices and no duplicates.
 
@@ -691,7 +873,6 @@ def sorted_indices(self):
 
         .. warning::
             Calling this function might synchronize the device.
-
         """
         # Taken from SciPy as is.
         A = self.copy()
@@ -715,7 +896,6 @@ def sum_duplicates(self):
         .. seealso::
            :meth:`scipy.sparse.csr_matrix.sum_duplicates`,
            :meth:`scipy.sparse.csc_matrix.sum_duplicates`
-
         """
         if self.has_canonical_format:
             return
diff --git a/cupyx/scipy/sparse/csr.py b/cupyx/scipy/sparse/csr.py
--- a/cupyx/scipy/sparse/csr.py
+++ b/cupyx/scipy/sparse/csr.py
@@ -279,6 +279,7 @@ def multiply(self, other):
             raise TypeError(msg)
 
     # TODO(unno): Implement prune
+
     # TODO(unno): Implement reshape
 
     def sort_indices(self):


Test patch:
diff --git a/tests/cupyx_tests/scipy_tests/sparse_tests/test_index.py b/tests/cupyx_tests/scipy_tests/sparse_tests/test_index.py
--- a/tests/cupyx_tests/scipy_tests/sparse_tests/test_index.py
+++ b/tests/cupyx_tests/scipy_tests/sparse_tests/test_index.py
@@ -9,10 +9,19 @@
     pass
 
 import cupy
+import cupyx
 from cupy import testing
 from cupyx.scipy import sparse
 
 
+def _get_index_combos(idx):
+    return [dict['arr_fn'](idx, dtype=dict['dtype'])
+            for dict in testing.product({
+                "arr_fn": [numpy.array, cupy.array],
+                "dtype": [numpy.int32, numpy.int64]
+            })]
+
+
 def _check_shares_memory(xp, sp, x, y):
     if sp.issparse(x) and sp.issparse(y):
         assert not xp.shares_memory(x.indptr, y.indptr)
@@ -20,6 +29,293 @@ def _check_shares_memory(xp, sp, x, y):
         assert not xp.shares_memory(x.data, y.data)
 
 
+@testing.parameterize(*testing.product({
+    'format': ['csr', 'csc'],
+    'density': [0.9],
+    'dtype': ['float32', 'float64', 'complex64', 'complex128'],
+    'n_rows': [25, 150],
+    'n_cols': [25, 150]
+}))
+@testing.with_requires('scipy>=1.4.0')
+@testing.gpu
+class TestSetitemIndexing(unittest.TestCase):
+
+    def _run(self, maj, min=None, data=5):
+
+        import scipy.sparse
+        for i in range(2):
+            shape = self.n_rows, self.n_cols
+            a = testing.shaped_sparse_random(
+                shape, sparse, self.dtype, self.density, self.format)
+            expected = testing.shaped_sparse_random(
+                shape, scipy.sparse, self.dtype, self.density, self.format)
+
+            maj_h = maj.get() if isinstance(maj, cupy.ndarray) else maj
+            min_h = min.get() if isinstance(min, cupy.ndarray) else min
+
+            data_is_cupy = isinstance(data, (cupy.ndarray, sparse.spmatrix))
+            data_h = data.get() if data_is_cupy else data
+
+            if min is not None:
+                actual = a
+                actual[maj, min] = data
+
+                expected[maj_h, min_h] = data_h
+            else:
+                actual = a
+                actual[maj] = data
+
+                expected[maj_h] = data_h
+
+        if cupyx.scipy.sparse.isspmatrix(actual):
+            actual.sort_indices()
+            expected.sort_indices()
+
+            cupy.testing.assert_array_equal(
+                actual.indptr, expected.indptr)
+            cupy.testing.assert_array_equal(
+                actual.indices, expected.indices)
+            cupy.testing.assert_array_equal(
+                actual.data, expected.data)
+
+        else:
+
+            cupy.testing.assert_array_equal(
+                actual.ravel(), cupy.array(expected).ravel())
+
+    def test_set_sparse(self):
+
+        x = cupyx.scipy.sparse.random(1, 5, format='csr', density=0.8)
+
+        # Test inner indexing with sparse data
+        for maj, min in zip(_get_index_combos([0, 1, 2, 3, 5]),
+                            _get_index_combos([1, 2, 3, 4, 5])):
+            self._run(maj, min, data=x)
+        self._run([0, 1, 2, 3, 5], [1, 2, 3, 4, 5], data=x)
+
+        # Test 2d major indexing 1d minor indexing with sparse data
+        for maj, min in zip(_get_index_combos([[0], [1], [2], [3], [5]]),
+                            _get_index_combos([1, 2, 3, 4, 5])):
+            self._run(maj, min, data=x)
+        self._run([[0], [1], [2], [3], [5]], [1, 2, 3, 4, 5], data=x)
+
+        # Test 1d major indexing 2d minor indexing with sparse data
+        for maj, min in zip(_get_index_combos([0, 1, 2, 3, 5]),
+                            _get_index_combos([[1], [2], [3], [4], [5]])):
+            self._run(maj, min, data=x)
+        self._run([0, 1, 2, 3, 5], [[1], [2], [3], [4], [5]], data=x)
+
+        # Test minor indexing numpy scalar / cupy 0-dim
+        for maj, min in zip(_get_index_combos([0, 2, 4, 5, 6]),
+                            _get_index_combos(1)):
+            self._run(maj, min, data=x)
+
+    @testing.with_requires('scipy>=1.5.0')
+    def test_set_zero_dim_bool_mask(self):
+
+        zero_dim_data = [numpy.array(5), cupy.array(5)]
+
+        for data in zero_dim_data:
+            self._run([False, True], data=data)
+
+    def test_set_zero_dim_scalar(self):
+
+        zero_dim_data = [numpy.array(5), cupy.array(5)]
+
+        for data in zero_dim_data:
+            self._run(slice(5, 10000), data=data)
+            self._run([1, 5, 4, 5], data=data)
+            self._run(0, 2, data=data)
+
+    def test_major_slice(self):
+        self._run(slice(5, 10000), data=5)
+        self._run(slice(5, 4), data=5)
+        self._run(slice(4, 5, 2), data=5)
+        self._run(slice(5, 4, -2), data=5)
+        self._run(slice(2, 4), slice(0, 2), [[4], [1]])
+        self._run(slice(2, 4), slice(0, 2), [[4, 5], [6, 7]])
+        self._run(slice(2, 4), 0, [[4], [6]])
+
+        self._run(slice(5, 9))
+        self._run(slice(9, 5))
+
+    def test_major_all(self):
+        self._run(slice(None))
+
+    def test_major_scalar(self):
+        self._run(10)
+
+    def test_major_fancy(self):
+        self._run([1, 5, 4])
+        self._run([10, 2])
+        self._run([2])
+
+    def test_major_slice_minor_slice(self):
+        self._run(slice(1, 5), slice(1, 5))
+
+    def test_major_slice_minor_all(self):
+        self._run(slice(1, 5), slice(None))
+        self._run(slice(5, 1), slice(None))
+
+    def test_major_slice_minor_scalar(self):
+        self._run(slice(1, 5), 5)
+        self._run(slice(5, 1), 5)
+        self._run(slice(5, 1, -1), 5)
+
+    def test_major_slice_minor_fancy(self):
+        self._run(slice(1, 10, 2), [1, 5, 4])
+
+    def test_major_scalar_minor_slice(self):
+        self._run(5, slice(1, 5))
+
+    def test_major_scalar_minor_all(self):
+        self._run(5, slice(None))
+
+    def test_major_scalar_minor_scalar(self):
+        self._run(5, 5)
+        self._run(10, 24, 5)
+
+    def test_major_scalar_minor_fancy(self):
+        self._run(5, [1, 5, 4])
+
+    def test_major_all_minor_all(self):
+        self._run(slice(None), slice(None))
+
+    def test_major_all_minor_fancy(self):
+        for min in _get_index_combos(
+                [0, 3, 4, 1, 1, 5, 5, 2, 3, 4, 5, 4, 1, 5]):
+            self._run(slice(None), min)
+
+        self._run(slice(None), [0, 3, 4, 1, 1, 5, 5, 2, 3, 4, 5, 4, 1, 5])
+
+    def test_major_fancy_minor_fancy(self):
+
+        for maj, min in zip(_get_index_combos([1, 2, 3, 4, 1, 6, 1, 8, 9]),
+                            _get_index_combos([1, 5, 2, 3, 4, 5, 4, 1, 5])):
+            self._run(maj, min)
+
+        self._run([1, 2, 3, 4, 1, 6, 1, 8, 9], [1, 5, 2, 3, 4, 5, 4, 1, 5])
+
+        for idx in _get_index_combos([1, 5, 4]):
+            self._run(idx, idx)
+        self._run([1, 5, 4], [1, 5, 4])
+
+        for maj, min in zip(_get_index_combos([2, 0, 10]),
+                            _get_index_combos([9, 2, 1])):
+            self._run(maj, min)
+        self._run([2, 0, 10], [9, 2, 1])
+
+        for maj, min in zip(_get_index_combos([2, 9]),
+                            _get_index_combos([2, 1])):
+            self._run(maj, min)
+        self._run([2, 0], [2, 1])
+
+    def test_major_fancy_minor_all(self):
+
+        for idx in _get_index_combos([1, 5, 4]):
+            self._run(idx, slice(None))
+        self._run([1, 5, 4], slice(None))
+
+    def test_major_fancy_minor_scalar(self):
+
+        for idx in _get_index_combos([1, 5, 4]):
+            self._run(idx, 5)
+        self._run([1, 5, 4], 5)
+
+    def test_major_fancy_minor_slice(self):
+
+        for idx in _get_index_combos([1, 5, 4]):
+            self._run(idx, slice(1, 5))
+            self._run(idx, slice(5, 1, -1))
+        self._run([1, 5, 4], slice(1, 5))
+        self._run([1, 5, 4], slice(5, 1, -1))
+
+    def test_major_bool_fancy(self):
+        rand_bool = cupy.random.random(self.n_rows).astype(cupy.bool)
+        self._run(rand_bool)
+
+    def test_major_slice_with_step(self):
+
+        # positive step
+        self._run(slice(1, 10, 2))
+        self._run(slice(2, 10, 5))
+        self._run(slice(0, 10, 10))
+
+        self._run(slice(1, None, 2))
+        self._run(slice(2, None, 5))
+        self._run(slice(0, None,  10))
+
+        # negative step
+        self._run(slice(10, 1, -2))
+        self._run(slice(10, 2, -5))
+        self._run(slice(10, 0, -10))
+
+        self._run(slice(10, None, -2))
+        self._run(slice(10, None, -5))
+        self._run(slice(10, None, -10))
+
+    def test_major_slice_with_step_minor_slice_with_step(self):
+
+        # positive step
+        self._run(slice(1, 10, 2), slice(1, 10, 2))
+        self._run(slice(2, 10, 5), slice(2, 10, 5))
+        self._run(slice(0, 10, 10), slice(0, 10, 10))
+
+        # negative step
+        self._run(slice(10, 1, 2), slice(10, 1, 2))
+        self._run(slice(10, 2, 5), slice(10, 2, 5))
+        self._run(slice(10, 0, 10), slice(10, 0, 10))
+
+    def test_major_slice_with_step_minor_all(self):
+
+        # positive step
+        self._run(slice(1, 10, 2), slice(None))
+        self._run(slice(2, 10, 5), slice(None))
+        self._run(slice(0, 10, 10), slice(None))
+
+        # negative step
+        self._run(slice(10, 1, 2), slice(None))
+        self._run(slice(10, 2, 5), slice(None))
+        self._run(slice(10, 0, 10), slice(None))
+
+    @testing.with_requires('scipy>=1.5.0')
+    def test_fancy_setting_bool(self):
+        # Unfortunately, boolean setting is implemented slightly
+        # differently between Scipy 1.4 and 1.5. Using the most
+        # up-to-date version in CuPy.
+
+        for maj in _get_index_combos(
+                [[True], [False], [False], [True], [True], [True]]):
+            self._run(maj, data=5)
+        self._run([[True], [False], [False], [True], [True], [True]], data=5)
+
+        for maj in _get_index_combos([True, False, False, True, True, True]):
+            self._run(maj, data=5)
+        self._run([True, False, False, True, True, True], data=5)
+
+        for maj in _get_index_combos([[True], [False], [True]]):
+            self._run(maj, data=5)
+        self._run([[True], [False], [True]], data=5)
+
+    def test_fancy_setting(self):
+
+        for maj, data in zip(_get_index_combos([0, 5, 10, 2]),
+                             _get_index_combos([1, 2, 3, 2])):
+            self._run(maj, 0, data)
+        self._run([0, 5, 10, 2], 0, [1, 2, 3, 2])
+
+        # Indexes with duplicates should follow 'last-in-wins'
+        # But Cupy dense indexing doesn't support this yet:
+        # ref: https://github.com/cupy/cupy/issues/3836
+        # Starting with an empty array for now, since insertions
+        # use `last-in-wins`.
+        self.density = 0.0  # Zeroing out density to force only insertions
+        for maj, min, data in zip(_get_index_combos([0, 5, 10, 2, 0, 10]),
+                                  _get_index_combos([1, 2, 3, 4, 1, 3]),
+                                  _get_index_combos([1, 2, 3, 4, 5, 6])):
+            self._run(maj, min, data)
+
+
 class IndexingTestBase(unittest.TestCase):
 
     def _make_matrix(self, sp, dtype):