Hi scipy developers,
I’d like to propose a new sparse matrix format for SciPy: Multiply-Compressed Sparse Row (MCSR). I have a working implementation (Python + C++ sparsetools) and am looking for feedback before opening a PR.
The motivation for this format is based on the observation that many real-world sparse matrices have rows in which the same nonzero value appears many times. For example:
- Single-cell RNA sequencing (scRNA-seq) count matrices. Lowly-expressed genes produce many identical small integer counts in each of many thousands of cells.
- Adjacency matrices of weighted graphs with discrete edge weights.
- Potentially in quantized or rounded scientific data.
CSR stores every nonzero value individually. If a row has 5,000 nonzero entries but only 10 distinct values, CSR wastes space storing the same number thousands of times. MCSR compresses this redundancy via run-length encoding (RLE) on the values within each row.
Format description
MCSR is a lossless compression of CSR that performs run-length encoding of the nonzero data values. For an M × N matrix, it stores five arrays:
| Array | Type | Length | Description |
|---|---|---|---|
indices |
int | nnz |
Column indices (sorted by value within each row) |
indptr |
int | M+1 |
Row pointer into indices (identical semantics to CSR) |
data |
Any | nrle |
Unique nonzero values per row, sorted per row and concatenated |
counts |
uint | nrle |
Run length for each entry in data. sum(counts) == nnz. |
counts_indptr |
int | M+1 |
Row pointer into data and counts. |
Where nrle is the total number of (value, count) pairs across all rows.
Code to reconstruct row i is then:
rle_start, rle_end = counts_indptr[i], counts_indptr[i + 1]
row_data = np.repeat(data[rle_start:rle_end], counts[rle_start:rle_end])
row_indices = indices[indptr[i]:indptr[i + 1]]
Both CSR and MCSR store arrays of size nnz and M+1, but MCSR compresses the data values from nnz to 2 * nrle. In practice, on a variety of single cell datasets, this yields in-memory compression ratios MCSR:CSR of 0.51:1.0.
Because the total number of data values stored can be much smaller than CSR, performance of arithmetic operations can also be much more efficient. Evaluation of iter, row/column/global sums, log1p and expm1, and extraction of the top 1k highest values per row across a representative set of 457 single cell datasets show average 3.1- to 17.2-fold performance improvement.
A related work ( Value-Compressed Sparse Column (VCSC): Sparse Matrix Storage for Single-cell Omics Data ) that independently developed essentially the same implementation, but column-oriented, performed additional benchmarking on random datasets and show similar gains and the degradation of performance as data redundancy decreases.
I have an initial implementation at GitHub - cmclean/scipy at multiply-compressed-sparse-row · GitHub . It consists of the Python classes mcsr_array and mcsr_matrix that follow the sparray / spmatrix pattern, construction from dense and sparse matrices, full IndexMixin support, a variety of arithmetic and aggregation functions, and matrix multiplication routines, all optimized using C++ sparsetools kernels. The main code is reasonably solid at this point; while the test coverage is high now that I better understand the testing setup for sparse formats it would need an overhaul to match the current conventions of the library.
So I guess my main question is whether there is interest in adding this sparse format to SciPy? It would be directly beneficial for single cell omics data, particularly if integrated for support in AnnData which I have looked into and would be straightforward. I understand the maintenance burden and am happy to discuss scope. Also happy to discuss other things like the naming, whether a column-oriented analog should exist, or additional performance-critical use cases I should benchmark against. Thanks for considering!
