numba-mwu 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.3
2
+ Name: numba-mwu
3
+ Version: 0.1.1
4
+ Summary: Numba-accelerated Mann-Whitney U test with sparse matrix support.
5
+ Author: Noam Teyssier
6
+ Author-email: Noam Teyssier <22600644+noamteyssier@users.noreply.github.com>
7
+ Requires-Dist: numba>=0.64.0
8
+ Requires-Dist: scipy>=1.17.1
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+
12
+ # numba-mwu
13
+
14
+ Numba-accelerated Mann-Whitney U test.
15
+ Drop-in replacement for `scipy.stats.mannwhitneyu` with parallel batch operations and native sparse matrix support.
16
+
17
+ All functions use the asymptotic (normal approximation) method and produce results identical to `scipy.stats.mannwhitneyu(..., method="asymptotic")`.
18
+
19
+ > Note: This is only supported for 1D and 2D inputs.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ uv pip install numba-mwu
25
+ ```
26
+
27
+ ## API
28
+
29
+ Every function returns a `MannWhitneyUResult` named tuple with `statistic` and `pvalue` fields. The batch functions return arrays instead of scalars.
30
+
31
+ All functions accept `use_continuity` (default `True`) and `alternative` (`"two-sided"`, `"less"`, `"greater"`).
32
+
33
+ ### `mannwhitneyu(x, y)`
34
+
35
+ Single two-sample test. Equivalent to scipy's `mannwhitneyu`.
36
+
37
+ ```python
38
+ from numba_mwu import mannwhitneyu
39
+
40
+ result = mannwhitneyu(x, y)
41
+ result.statistic # U statistic
42
+ result.pvalue # two-sided p-value
43
+ ```
44
+
45
+ ### `mannwhitneyu_rows(X, y)`
46
+
47
+ Test each row of a 2-D array `X` against a shared reference sample `y`.
48
+ Parallelized across rows.
49
+
50
+ ```python
51
+ from numba_mwu import mannwhitneyu_rows
52
+
53
+ # X: (n_tests, n1), y: (n2,)
54
+ result = mannwhitneyu_rows(X, y)
55
+ result.statistic # shape (n_tests,)
56
+ result.pvalue # shape (n_tests,)
57
+ ```
58
+
59
+ ### `mannwhitneyu_columns(X, Y)`
60
+
61
+ Test each column of `X` against the corresponding column of `Y`.
62
+ Parallelized across columns.
63
+ Designed for the common case of slicing a cells-by-genes matrix into two groups:
64
+
65
+ ```python
66
+ from numba_mwu import mannwhitneyu_columns
67
+
68
+ # expression: (n_cells, n_genes), labels: (n_cells,)
69
+ X = expression[labels == "A"] # (n1, n_genes)
70
+ Y = expression[labels == "B"] # (n2, n_genes)
71
+
72
+ result = mannwhitneyu_columns(X, Y)
73
+ result.statistic # shape (n_genes,)
74
+ result.pvalue # shape (n_genes,)
75
+ ```
76
+
77
+ ### `mannwhitneyu_sparse(X, Y)`
78
+
79
+ Same as `mannwhitneyu_columns` but operates directly on CSR sparse matrices without converting to dense.
80
+
81
+ Memory overhead per matrix is one `int64` array of length `nnz` (column permutation) plus one `int64` array of length `n_genes + 1` (column pointers).
82
+ No data values are copied.
83
+
84
+ Requires non-negative data (raw counts, normalized expression, etc.).
85
+
86
+ > Note: Call `eliminate_zeros()` on each matrix beforehand if it may contain explicitly stored zeros.
87
+
88
+ ```python
89
+ from numba_mwu import mannwhitneyu_sparse
90
+
91
+ # adata.X is a CSR matrix, adata.obs["group"] has labels
92
+ mask = adata.obs["group"] == "A"
93
+ X = adata.X[mask] # CSR row-slice is still CSR
94
+ Y = adata.X[~mask]
95
+
96
+
97
+ result = mannwhitneyu_sparse(X, Y)
98
+ result.statistic # shape (n_genes,)
99
+ result.pvalue # shape (n_genes,)
100
+ ```
101
+
102
+ ## Benchmarks
103
+
104
+ Run benchmarks with:
105
+
106
+ ```bash
107
+ uv run benchmarks/bench_mwu.py
108
+ ```
109
+
110
+ ```text
111
+ ================================================================================
112
+ SINGLE PAIR BENCHMARKS (overhead comparison)
113
+ ================================================================================
114
+
115
+ --- integer data ---
116
+ scenario scipy numba speedup
117
+ -----------------------------------------------------------------
118
+ n=20 vs n=20 223.1 us 3.9 us 56.9x
119
+ n=100 vs n=100 224.0 us 5.4 us 41.7x
120
+ n=500 vs n=500 248.3 us 12.6 us 19.7x
121
+ n=1000 vs n=1000 287.2 us 22.7 us 12.7x
122
+
123
+ --- float data ---
124
+ scenario scipy numba speedup
125
+ -----------------------------------------------------------------
126
+ n=20 vs n=20 212.6 us 3.9 us 53.9x
127
+ n=100 vs n=100 220.7 us 5.6 us 39.4x
128
+ n=500 vs n=500 249.4 us 14.7 us 16.9x
129
+ n=1000 vs n=1000 287.3 us 27.4 us 10.5x
130
+
131
+ ================================================================================
132
+ DENSE MATRIX BENCHMARKS
133
+ ================================================================================
134
+
135
+ --- integer data ---
136
+ scenario scipy numba speedup
137
+ -----------------------------------------------------------------
138
+ small (100x50) 11.4 ms 64.1 us 177.8x
139
+ medium (1000x500) 139.5 ms 1.5 ms 94.0x
140
+ large (5000x2000) 1.01 s 43.7 ms 23.0x
141
+ xlarge (10000x5000) 3.93 s 179.5 ms 21.9x
142
+
143
+ --- float data ---
144
+ scenario scipy numba speedup
145
+ -----------------------------------------------------------------
146
+ small (100x50) 11.1 ms 53.0 us 208.5x
147
+ medium (1000x500) 131.5 ms 1.2 ms 109.1x
148
+ large (5000x2000) 866.6 ms 36.0 ms 24.1x
149
+ xlarge (10000x5000) 3.33 s 151.9 ms 22.0x
150
+
151
+ ================================================================================
152
+ SPARSE MATRIX BENCHMARKS
153
+ ================================================================================
154
+
155
+ --- integer data ---
156
+ scenario scipy (dense) numba sparse numba dense sp speedup
157
+ -------------------------------------------------------------------------------------
158
+ small 90% (200x100) 22.7 ms 51.3 us 84.3 us 442.3x
159
+ medium 90% (2000x1000) 275.5 ms 1.0 ms 3.5 ms 266.9x
160
+ large 95% (5000x2000) 746.8 ms 2.6 ms 20.4 ms 282.1x
161
+ xlarge 95% (10000x5000) 2.80 s 21.1 ms 117.2 ms 132.6x
162
+
163
+ --- float data ---
164
+ scenario scipy (dense) numba sparse numba dense sp speedup
165
+ -------------------------------------------------------------------------------------
166
+ small 90% (200x100) 22.7 ms 53.2 us 80.7 us 427.0x
167
+ medium 90% (2000x1000) 279.5 ms 1.0 ms 4.3 ms 268.9x
168
+ large 95% (5000x2000) 741.1 ms 3.5 ms 23.7 ms 209.4x
169
+ xlarge 95% (10000x5000) 2.80 s 21.0 ms 111.5 ms 133.0x
170
+ ```
@@ -0,0 +1,159 @@
1
+ # numba-mwu
2
+
3
+ Numba-accelerated Mann-Whitney U test.
4
+ Drop-in replacement for `scipy.stats.mannwhitneyu` with parallel batch operations and native sparse matrix support.
5
+
6
+ All functions use the asymptotic (normal approximation) method and produce results identical to `scipy.stats.mannwhitneyu(..., method="asymptotic")`.
7
+
8
+ > Note: This is only supported for 1D and 2D inputs.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ uv pip install numba-mwu
14
+ ```
15
+
16
+ ## API
17
+
18
+ Every function returns a `MannWhitneyUResult` named tuple with `statistic` and `pvalue` fields. The batch functions return arrays instead of scalars.
19
+
20
+ All functions accept `use_continuity` (default `True`) and `alternative` (`"two-sided"`, `"less"`, `"greater"`).
21
+
22
+ ### `mannwhitneyu(x, y)`
23
+
24
+ Single two-sample test. Equivalent to scipy's `mannwhitneyu`.
25
+
26
+ ```python
27
+ from numba_mwu import mannwhitneyu
28
+
29
+ result = mannwhitneyu(x, y)
30
+ result.statistic # U statistic
31
+ result.pvalue # two-sided p-value
32
+ ```
33
+
34
+ ### `mannwhitneyu_rows(X, y)`
35
+
36
+ Test each row of a 2-D array `X` against a shared reference sample `y`.
37
+ Parallelized across rows.
38
+
39
+ ```python
40
+ from numba_mwu import mannwhitneyu_rows
41
+
42
+ # X: (n_tests, n1), y: (n2,)
43
+ result = mannwhitneyu_rows(X, y)
44
+ result.statistic # shape (n_tests,)
45
+ result.pvalue # shape (n_tests,)
46
+ ```
47
+
48
+ ### `mannwhitneyu_columns(X, Y)`
49
+
50
+ Test each column of `X` against the corresponding column of `Y`.
51
+ Parallelized across columns.
52
+ Designed for the common case of slicing a cells-by-genes matrix into two groups:
53
+
54
+ ```python
55
+ from numba_mwu import mannwhitneyu_columns
56
+
57
+ # expression: (n_cells, n_genes), labels: (n_cells,)
58
+ X = expression[labels == "A"] # (n1, n_genes)
59
+ Y = expression[labels == "B"] # (n2, n_genes)
60
+
61
+ result = mannwhitneyu_columns(X, Y)
62
+ result.statistic # shape (n_genes,)
63
+ result.pvalue # shape (n_genes,)
64
+ ```
65
+
66
+ ### `mannwhitneyu_sparse(X, Y)`
67
+
68
+ Same as `mannwhitneyu_columns` but operates directly on CSR sparse matrices without converting to dense.
69
+
70
+ Memory overhead per matrix is one `int64` array of length `nnz` (column permutation) plus one `int64` array of length `n_genes + 1` (column pointers).
71
+ No data values are copied.
72
+
73
+ Requires non-negative data (raw counts, normalized expression, etc.).
74
+
75
+ > Note: Call `eliminate_zeros()` on each matrix beforehand if it may contain explicitly stored zeros.
76
+
77
+ ```python
78
+ from numba_mwu import mannwhitneyu_sparse
79
+
80
+ # adata.X is a CSR matrix, adata.obs["group"] has labels
81
+ mask = adata.obs["group"] == "A"
82
+ X = adata.X[mask] # CSR row-slice is still CSR
83
+ Y = adata.X[~mask]
84
+
85
+
86
+ result = mannwhitneyu_sparse(X, Y)
87
+ result.statistic # shape (n_genes,)
88
+ result.pvalue # shape (n_genes,)
89
+ ```
90
+
91
+ ## Benchmarks
92
+
93
+ Run benchmarks with:
94
+
95
+ ```bash
96
+ uv run benchmarks/bench_mwu.py
97
+ ```
98
+
99
+ ```text
100
+ ================================================================================
101
+ SINGLE PAIR BENCHMARKS (overhead comparison)
102
+ ================================================================================
103
+
104
+ --- integer data ---
105
+ scenario scipy numba speedup
106
+ -----------------------------------------------------------------
107
+ n=20 vs n=20 223.1 us 3.9 us 56.9x
108
+ n=100 vs n=100 224.0 us 5.4 us 41.7x
109
+ n=500 vs n=500 248.3 us 12.6 us 19.7x
110
+ n=1000 vs n=1000 287.2 us 22.7 us 12.7x
111
+
112
+ --- float data ---
113
+ scenario scipy numba speedup
114
+ -----------------------------------------------------------------
115
+ n=20 vs n=20 212.6 us 3.9 us 53.9x
116
+ n=100 vs n=100 220.7 us 5.6 us 39.4x
117
+ n=500 vs n=500 249.4 us 14.7 us 16.9x
118
+ n=1000 vs n=1000 287.3 us 27.4 us 10.5x
119
+
120
+ ================================================================================
121
+ DENSE MATRIX BENCHMARKS
122
+ ================================================================================
123
+
124
+ --- integer data ---
125
+ scenario scipy numba speedup
126
+ -----------------------------------------------------------------
127
+ small (100x50) 11.4 ms 64.1 us 177.8x
128
+ medium (1000x500) 139.5 ms 1.5 ms 94.0x
129
+ large (5000x2000) 1.01 s 43.7 ms 23.0x
130
+ xlarge (10000x5000) 3.93 s 179.5 ms 21.9x
131
+
132
+ --- float data ---
133
+ scenario scipy numba speedup
134
+ -----------------------------------------------------------------
135
+ small (100x50) 11.1 ms 53.0 us 208.5x
136
+ medium (1000x500) 131.5 ms 1.2 ms 109.1x
137
+ large (5000x2000) 866.6 ms 36.0 ms 24.1x
138
+ xlarge (10000x5000) 3.33 s 151.9 ms 22.0x
139
+
140
+ ================================================================================
141
+ SPARSE MATRIX BENCHMARKS
142
+ ================================================================================
143
+
144
+ --- integer data ---
145
+ scenario scipy (dense) numba sparse numba dense sp speedup
146
+ -------------------------------------------------------------------------------------
147
+ small 90% (200x100) 22.7 ms 51.3 us 84.3 us 442.3x
148
+ medium 90% (2000x1000) 275.5 ms 1.0 ms 3.5 ms 266.9x
149
+ large 95% (5000x2000) 746.8 ms 2.6 ms 20.4 ms 282.1x
150
+ xlarge 95% (10000x5000) 2.80 s 21.1 ms 117.2 ms 132.6x
151
+
152
+ --- float data ---
153
+ scenario scipy (dense) numba sparse numba dense sp speedup
154
+ -------------------------------------------------------------------------------------
155
+ small 90% (200x100) 22.7 ms 53.2 us 80.7 us 427.0x
156
+ medium 90% (2000x1000) 279.5 ms 1.0 ms 4.3 ms 268.9x
157
+ large 95% (5000x2000) 741.1 ms 3.5 ms 23.7 ms 209.4x
158
+ xlarge 95% (10000x5000) 2.80 s 21.0 ms 111.5 ms 133.0x
159
+ ```
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "numba-mwu"
3
+ version = "0.1.1"
4
+ description = "Numba-accelerated Mann-Whitney U test with sparse matrix support."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Noam Teyssier", email = "22600644+noamteyssier@users.noreply.github.com" }
8
+ ]
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "numba>=0.64.0",
12
+ "scipy>=1.17.1",
13
+ ]
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.9.22,<0.10.0"]
17
+ build-backend = "uv_build"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "marimo>=0.20.2",
22
+ "pytest>=9.0.2",
23
+ "ruff>=0.15.2",
24
+ "ty>=0.0.18",
25
+ ]
@@ -0,0 +1,281 @@
1
+ """Numba-accelerated Mann-Whitney U test."""
2
+
3
+ from collections import namedtuple
4
+
5
+ import numpy as np
6
+
7
+ from ._batch import _mannwhitneyu_columns, _mannwhitneyu_rows
8
+ from ._core import GREATER, LESS, TWO_SIDED, _mannwhitneyu_single
9
+ from ._sparse import _build_col_index, _sparse_mwu_batch
10
+
11
+ __all__ = [
12
+ "MannWhitneyUResult",
13
+ "SparseColumnIndex",
14
+ "mannwhitneyu",
15
+ "mannwhitneyu_rows",
16
+ "mannwhitneyu_columns",
17
+ "mannwhitneyu_sparse",
18
+ "sparse_column_index",
19
+ ]
20
+
21
+ MannWhitneyUResult = namedtuple("MannWhitneyUResult", ("statistic", "pvalue"))
22
+ SparseColumnIndex = namedtuple(
23
+ "SparseColumnIndex", ("data", "col_indptr", "col_order", "n_rows", "n_cols")
24
+ )
25
+
26
+ _ALTERNATIVE_MAP = {
27
+ "two-sided": TWO_SIDED,
28
+ "less": LESS,
29
+ "greater": GREATER,
30
+ }
31
+
32
+
33
+ def _validate_alternative(alternative):
34
+ alt = alternative.lower()
35
+ if alt not in _ALTERNATIVE_MAP:
36
+ raise ValueError(
37
+ f"`alternative` must be one of {set(_ALTERNATIVE_MAP)}, got {alternative!r}"
38
+ )
39
+ return _ALTERNATIVE_MAP[alt]
40
+
41
+
42
+ def _validate_1d(arr, name):
43
+ arr = np.asarray(arr, dtype=np.float64)
44
+ if arr.ndim != 1:
45
+ raise ValueError(f"`{name}` must be 1-dimensional, got ndim={arr.ndim}")
46
+ if arr.shape[0] == 0:
47
+ raise ValueError(f"`{name}` must be of nonzero size.")
48
+ if np.any(np.isnan(arr)):
49
+ raise ValueError(f"`{name}` must not contain NaNs.")
50
+ return arr
51
+
52
+
53
+ def mannwhitneyu(x, y, use_continuity=True, alternative="two-sided"):
54
+ """Perform the Mann-Whitney U rank test on two independent samples.
55
+
56
+ Numba-accelerated asymptotic implementation equivalent to
57
+ ``scipy.stats.mannwhitneyu(..., method='asymptotic')``.
58
+
59
+ Parameters
60
+ ----------
61
+ x, y : array_like
62
+ 1-D arrays of samples.
63
+ use_continuity : bool, optional
64
+ Whether a continuity correction (1/2) should be applied. Default True.
65
+ alternative : {'two-sided', 'less', 'greater'}, optional
66
+ Defines the alternative hypothesis. Default is 'two-sided'.
67
+
68
+ Returns
69
+ -------
70
+ result : MannWhitneyUResult
71
+ Named tuple with ``statistic`` (U for sample x) and ``pvalue``.
72
+ """
73
+ x = _validate_1d(x, "x")
74
+ y = _validate_1d(y, "y")
75
+ alt = _validate_alternative(alternative)
76
+ stat, pval = _mannwhitneyu_single(x, y, use_continuity, alt)
77
+ return MannWhitneyUResult(stat, pval)
78
+
79
+
80
+ def mannwhitneyu_rows(X, y, use_continuity=True, alternative="two-sided"):
81
+ """Run Mann-Whitney U test for each row of X against y (parallelized).
82
+
83
+ Parameters
84
+ ----------
85
+ X : array_like, shape (n_tests, n1)
86
+ 2-D array where each row is a sample to test against y.
87
+ y : array_like, shape (n2,)
88
+ 1-D reference sample.
89
+ use_continuity : bool, optional
90
+ Whether a continuity correction (1/2) should be applied. Default True.
91
+ alternative : {'two-sided', 'less', 'greater'}, optional
92
+ Defines the alternative hypothesis. Default is 'two-sided'.
93
+
94
+ Returns
95
+ -------
96
+ result : MannWhitneyUResult
97
+ Named tuple with ``statistic`` and ``pvalue`` arrays of shape (n_tests,).
98
+ """
99
+ X = np.asarray(X, dtype=np.float64)
100
+ if X.ndim != 2:
101
+ raise ValueError(f"`X` must be 2-dimensional, got ndim={X.ndim}")
102
+ if X.shape[0] == 0:
103
+ raise ValueError("`X` must have at least one row.")
104
+ if np.any(np.isnan(X)):
105
+ raise ValueError("`X` must not contain NaNs.")
106
+ y = _validate_1d(y, "y")
107
+ alt = _validate_alternative(alternative)
108
+ stats, pvals = _mannwhitneyu_rows(X, y, use_continuity, alt)
109
+ return MannWhitneyUResult(stats, pvals)
110
+
111
+
112
+ def _validate_2d(arr, name):
113
+ arr = np.asarray(arr, dtype=np.float64)
114
+ if arr.ndim != 2:
115
+ raise ValueError(f"`{name}` must be 2-dimensional, got ndim={arr.ndim}")
116
+ if arr.shape[0] == 0:
117
+ raise ValueError(f"`{name}` must have at least one row.")
118
+ if np.any(np.isnan(arr)):
119
+ raise ValueError(f"`{name}` must not contain NaNs.")
120
+ return arr
121
+
122
+
123
+ def mannwhitneyu_columns(X, Y, use_continuity=True, alternative="two-sided"):
124
+ """Run Mann-Whitney U test on each column of X vs corresponding column of Y.
125
+
126
+ Designed for the common workflow where a matrix has been sliced by
127
+ group membership into two separate matrices (or views).
128
+
129
+ Parameters
130
+ ----------
131
+ X : array_like, shape (n1, n_genes)
132
+ 2-D array for group A (one column per gene/feature).
133
+ Y : array_like, shape (n2, n_genes)
134
+ 2-D array for group B. Must have the same number of columns as X.
135
+ use_continuity : bool, optional
136
+ Whether a continuity correction (1/2) should be applied. Default True.
137
+ alternative : {'two-sided', 'less', 'greater'}, optional
138
+ Defines the alternative hypothesis. Default is 'two-sided'.
139
+
140
+ Returns
141
+ -------
142
+ result : MannWhitneyUResult
143
+ Named tuple with ``statistic`` and ``pvalue`` arrays of shape (n_genes,).
144
+ """
145
+ X = _validate_2d(X, "X")
146
+ Y = _validate_2d(Y, "Y")
147
+ if X.shape[1] != Y.shape[1]:
148
+ raise ValueError(
149
+ f"`X` and `Y` must have the same number of columns, "
150
+ f"got {X.shape[1]} and {Y.shape[1]}."
151
+ )
152
+ alt = _validate_alternative(alternative)
153
+ stats, pvals = _mannwhitneyu_columns(X, Y, use_continuity, alt)
154
+ return MannWhitneyUResult(stats, pvals)
155
+
156
+
157
+ def _validate_csr(X, name):
158
+ from scipy.sparse import issparse, isspmatrix_csr
159
+
160
+ if not issparse(X):
161
+ raise TypeError(f"`{name}` must be a scipy sparse matrix.")
162
+ if not (isspmatrix_csr(X) or X.format == "csr"):
163
+ raise TypeError(
164
+ f"`{name}` must be in CSR format. Convert with `{name}.tocsr()` if needed."
165
+ )
166
+ if X.data.size > 0 and X.data.min() < 0:
167
+ raise ValueError(
168
+ f"Sparse MWU requires non-negative data in `{name}`. "
169
+ "For data with negative values, convert to dense and use "
170
+ "mannwhitneyu_columns."
171
+ )
172
+ return X
173
+
174
+
175
+ def sparse_column_index(X):
176
+ """Precompute a column index for a CSR sparse matrix.
177
+
178
+ The returned ``SparseColumnIndex`` can be passed to
179
+ ``mannwhitneyu_sparse`` in place of a raw CSR matrix, avoiding
180
+ redundant index construction when the same matrix is reused
181
+ across many comparisons::
182
+
183
+ ref_idx = sparse_column_index(ref_matrix)
184
+ for label in labels:
185
+ result = mannwhitneyu_sparse(group_matrix, ref_idx)
186
+
187
+ Parameters
188
+ ----------
189
+ X : scipy.sparse.csr_matrix or csr_array
190
+ Sparse matrix in CSR format with non-negative values.
191
+ Call ``X.eliminate_zeros()`` beforehand if it may contain
192
+ explicitly stored zeros.
193
+
194
+ Returns
195
+ -------
196
+ SparseColumnIndex
197
+ Precomputed index that can be passed to ``mannwhitneyu_sparse``.
198
+ """
199
+ X = _validate_csr(X, "X")
200
+ if X.shape[0] == 0:
201
+ raise ValueError("`X` must have at least one row.")
202
+ data = np.ascontiguousarray(X.data, dtype=np.float64)
203
+ indptr = np.ascontiguousarray(X.indptr)
204
+ indices = np.ascontiguousarray(X.indices)
205
+ col_indptr, col_order = _build_col_index(indptr, indices, X.shape[1])
206
+ return SparseColumnIndex(data, col_indptr, col_order, X.shape[0], X.shape[1])
207
+
208
+
209
+ def _resolve_sparse(arg, name):
210
+ """Convert a CSR matrix or SparseColumnIndex to a SparseColumnIndex."""
211
+ if isinstance(arg, SparseColumnIndex):
212
+ return arg
213
+ return sparse_column_index(arg)
214
+
215
+
216
+ def mannwhitneyu_sparse(X, Y, use_continuity=True, alternative="two-sided"):
217
+ """Run Mann-Whitney U test for each gene (column) of two sparse matrices.
218
+
219
+ Designed for single-cell expression matrices where rows are cells and
220
+ columns are genes. Each matrix represents one group — typically sliced
221
+ from a full expression matrix by cell labels::
222
+
223
+ X = full_matrix[labels == "A"] # CSR row-slice is still CSR
224
+ Y = full_matrix[labels == "B"]
225
+
226
+ Works directly with CSR format without converting to CSC or dense.
227
+ The only allocation per matrix is a column-index permutation array
228
+ (one int per nonzero entry) and column pointers (one int per gene).
229
+
230
+ Requires non-negative data — zeros must be the smallest values so they
231
+ form a contiguous block at the start of the sorted order. This holds
232
+ for raw counts, normalized expression, and any non-negative transformation.
233
+
234
+ Both ``X`` and ``Y`` can be either a CSR matrix or a precomputed
235
+ ``SparseColumnIndex`` (from ``sparse_column_index``). Precomputing
236
+ is useful when the same matrix is compared against many others.
237
+
238
+ Parameters
239
+ ----------
240
+ X : csr_matrix, csr_array, or SparseColumnIndex, shape (n1, n_genes)
241
+ Sparse expression matrix for group A. If a CSR matrix, must have
242
+ non-negative values. Call ``X.eliminate_zeros()`` beforehand if
243
+ the matrix may contain explicitly stored zeros.
244
+ Y : csr_matrix, csr_array, or SparseColumnIndex, shape (n2, n_genes)
245
+ Sparse expression matrix for group B. Must have the same number
246
+ of columns as X.
247
+ use_continuity : bool, optional
248
+ Whether to apply continuity correction. Default True.
249
+ alternative : {'two-sided', 'less', 'greater'}, optional
250
+ Alternative hypothesis. Default 'two-sided'.
251
+
252
+ Returns
253
+ -------
254
+ result : MannWhitneyUResult
255
+ Named tuple with ``statistic`` and ``pvalue`` arrays of shape
256
+ (n_genes,).
257
+ """
258
+ idx_a = _resolve_sparse(X, "X")
259
+ idx_b = _resolve_sparse(Y, "Y")
260
+
261
+ if idx_a.n_cols != idx_b.n_cols:
262
+ raise ValueError(
263
+ f"`X` and `Y` must have the same number of columns, "
264
+ f"got {idx_a.n_cols} and {idx_b.n_cols}."
265
+ )
266
+
267
+ alt = _validate_alternative(alternative)
268
+
269
+ stats, pvals = _sparse_mwu_batch(
270
+ idx_a.data,
271
+ idx_a.col_indptr,
272
+ idx_a.col_order,
273
+ idx_a.n_rows,
274
+ idx_b.data,
275
+ idx_b.col_indptr,
276
+ idx_b.col_order,
277
+ idx_b.n_rows,
278
+ use_continuity,
279
+ alt,
280
+ )
281
+ return MannWhitneyUResult(stats, pvals)
@@ -0,0 +1,60 @@
1
+ """Parallel batch Mann-Whitney U tests using numba.prange."""
2
+
3
+ import numba as nb
4
+ import numpy as np
5
+
6
+ from ._core import _mannwhitneyu_single
7
+
8
+
9
+ @nb.njit(parallel=True)
10
+ def _mannwhitneyu_rows(X, y, use_continuity, alternative):
11
+ """Run Mann-Whitney U test for each row of X against y.
12
+
13
+ Parameters
14
+ ----------
15
+ X : 2D float64 array (n_tests, n1)
16
+ y : 1D float64 array (n2,)
17
+ use_continuity : bool
18
+ alternative : int (0=two-sided, 1=less, 2=greater)
19
+
20
+ Returns
21
+ -------
22
+ U_out : float64 array (n_tests,)
23
+ p_out : float64 array (n_tests,)
24
+ """
25
+ n_tests = X.shape[0]
26
+ U_out = np.empty(n_tests, dtype=np.float64)
27
+ p_out = np.empty(n_tests, dtype=np.float64)
28
+ for i in nb.prange(n_tests): # type: ignore
29
+ U_out[i], p_out[i] = _mannwhitneyu_single(
30
+ X[i].copy(), y, use_continuity, alternative
31
+ )
32
+ return U_out, p_out
33
+
34
+
35
+ @nb.njit(parallel=True)
36
+ def _mannwhitneyu_columns(X, Y, use_continuity, alternative):
37
+ """Run Mann-Whitney U test on each column of X vs corresponding column of Y.
38
+
39
+ Parameters
40
+ ----------
41
+ X : 2D float64 array (n1, n_genes)
42
+ First group (one column per gene).
43
+ Y : 2D float64 array (n2, n_genes)
44
+ Second group (one column per gene). Must have same number of columns as X.
45
+ use_continuity : bool
46
+ alternative : int (0=two-sided, 1=less, 2=greater)
47
+
48
+ Returns
49
+ -------
50
+ U_out : float64 array (n_genes,)
51
+ p_out : float64 array (n_genes,)
52
+ """
53
+ n_genes = X.shape[1]
54
+ U_out = np.empty(n_genes, dtype=np.float64)
55
+ p_out = np.empty(n_genes, dtype=np.float64)
56
+ for i in nb.prange(n_genes): # type: ignore
57
+ x = X[:, i].copy() # ensure contiguous
58
+ y = Y[:, i].copy()
59
+ U_out[i], p_out[i] = _mannwhitneyu_single(x, y, use_continuity, alternative)
60
+ return U_out, p_out
@@ -0,0 +1,137 @@
1
+ """Numba-accelerated core functions for Mann-Whitney U test."""
2
+
3
+ import math
4
+
5
+ import numba as nb
6
+ import numpy as np
7
+
8
+ # Alternative hypothesis encoding
9
+ TWO_SIDED = 0
10
+ LESS = 1
11
+ GREATER = 2
12
+
13
+
14
+ @nb.njit
15
+ def _ndtr(x: float) -> float:
16
+ """Normal CDF using math.erfc."""
17
+ return 0.5 * math.erfc(-x / math.sqrt(2.0))
18
+
19
+
20
+ @nb.njit
21
+ def _rankdata_avg(x):
22
+ """Rank data using average method, returning ranks and tie group sizes.
23
+
24
+ Parameters
25
+ ----------
26
+ x : 1D float64 array
27
+
28
+ Returns
29
+ -------
30
+ ranks : float64 array of same length as x
31
+ tie_sizes : float64 array of length n_groups (number of distinct values)
32
+ """
33
+ n = x.shape[0]
34
+ idx = np.argsort(x)
35
+ ranks = np.empty(n, dtype=np.float64)
36
+ tie_sizes = np.empty(n, dtype=np.float64)
37
+ n_ties = 0
38
+
39
+ i = 0
40
+ while i < n:
41
+ j = i
42
+ while j < n - 1 and x[idx[j]] == x[idx[j + 1]]:
43
+ j += 1
44
+ avg_rank = (i + j) / 2.0 + 1.0
45
+ tie_count = j - i + 1
46
+ for k in range(i, j + 1):
47
+ ranks[idx[k]] = avg_rank
48
+ tie_sizes[n_ties] = float(tie_count)
49
+ n_ties += 1
50
+ i = j + 1
51
+
52
+ return ranks, tie_sizes[:n_ties]
53
+
54
+
55
+ @nb.njit
56
+ def _mannwhitneyu_single(x, y, use_continuity, alternative):
57
+ """Compute Mann-Whitney U test for two 1D samples (asymptotic method).
58
+
59
+ Parameters
60
+ ----------
61
+ x : 1D float64 array
62
+ y : 1D float64 array
63
+ use_continuity : bool
64
+ Whether to apply continuity correction.
65
+ alternative : int
66
+ 0 = two-sided, 1 = less, 2 = greater
67
+
68
+ Returns
69
+ -------
70
+ U1 : float
71
+ U statistic for sample x.
72
+ pvalue : float
73
+ """
74
+ n1 = x.shape[0]
75
+ n2 = y.shape[0]
76
+ n = n1 + n2
77
+
78
+ # Concatenate and rank
79
+ xy = np.empty(n, dtype=np.float64)
80
+ for i in range(n1):
81
+ xy[i] = x[i]
82
+ for i in range(n2):
83
+ xy[n1 + i] = y[i]
84
+
85
+ ranks, t = _rankdata_avg(xy)
86
+
87
+ # Sum of ranks for first sample
88
+ R1 = 0.0
89
+ for i in range(n1):
90
+ R1 += ranks[i]
91
+
92
+ U1 = R1 - n1 * (n1 + 1.0) / 2.0
93
+ U2 = n1 * n2 - U1
94
+
95
+ # Select statistic and multiplier based on alternative
96
+ if alternative == GREATER:
97
+ U = U1
98
+ f = 1.0
99
+ elif alternative == LESS:
100
+ U = U2
101
+ f = 1.0
102
+ else: # TWO_SIDED
103
+ U = max(U1, U2)
104
+ f = 2.0
105
+
106
+ # Tie correction term: sum(t_i^3 - t_i)
107
+ tie_term = 0.0
108
+ for i in range(t.shape[0]):
109
+ ti = t[i]
110
+ tie_term += ti * ti * ti - ti
111
+
112
+ # Standard deviation with tie correction
113
+ mu = n1 * n2 / 2.0
114
+ denom = n * (n - 1.0)
115
+ var_inner = (n + 1.0) - tie_term / denom
116
+ s_sq = n1 * n2 / 12.0 * var_inner
117
+
118
+ if s_sq <= 0.0:
119
+ # All values identical — no evidence of difference
120
+ return U1, 1.0
121
+
122
+ s = math.sqrt(s_sq)
123
+
124
+ numerator = U - mu
125
+ if use_continuity:
126
+ numerator -= 0.5
127
+
128
+ z = numerator / s
129
+ p = _ndtr(-z) * f
130
+
131
+ # Clip to [0, 1]
132
+ if p > 1.0:
133
+ p = 1.0
134
+ if p < 0.0:
135
+ p = 0.0
136
+
137
+ return U1, p
@@ -0,0 +1,236 @@
1
+ """Sparse-aware Mann-Whitney U test kernels for CSR matrices.
2
+
3
+ Works directly with CSR format (the standard for single-cell data) by
4
+ building a lightweight column index — a permutation array and column
5
+ pointers — without copying the data values. Memory overhead is one int
6
+ array of length nnz plus one int array of length (n_cols + 1) per matrix.
7
+ """
8
+
9
+ import math
10
+
11
+ import numba as nb
12
+ import numpy as np
13
+
14
+ from ._core import GREATER, LESS, _ndtr
15
+
16
+
17
+ @nb.njit
18
+ def _build_col_index(csr_indptr, csr_indices, n_cols):
19
+ """Build column pointers and a permutation from CSR arrays.
20
+
21
+ Returns
22
+ -------
23
+ col_indptr : int64 array (n_cols + 1)
24
+ col_indptr[j] .. col_indptr[j+1] gives the range of entries for
25
+ column j in the col_order array.
26
+ col_order : int64 array (nnz)
27
+ Indices into the CSR data arrays, grouped by column.
28
+ data[col_order[col_indptr[j]:col_indptr[j+1]]] gives the nonzero
29
+ values for column j.
30
+ """
31
+ nnz = csr_indices.shape[0]
32
+
33
+ # Count entries per column
34
+ col_counts = np.zeros(n_cols, dtype=np.int64)
35
+ for k in range(nnz):
36
+ col_counts[csr_indices[k]] += 1
37
+
38
+ # Build col_indptr via cumulative sum
39
+ col_indptr = np.empty(n_cols + 1, dtype=np.int64)
40
+ col_indptr[0] = 0
41
+ for j in range(n_cols):
42
+ col_indptr[j + 1] = col_indptr[j] + col_counts[j]
43
+
44
+ # Fill col_order (scatter each entry to its column bucket)
45
+ pos = np.empty(n_cols, dtype=np.int64)
46
+ for j in range(n_cols):
47
+ pos[j] = col_indptr[j]
48
+
49
+ col_order = np.empty(nnz, dtype=np.int64)
50
+ n_rows = csr_indptr.shape[0] - 1
51
+ for i in range(n_rows):
52
+ for k in range(csr_indptr[i], csr_indptr[i + 1]):
53
+ c = csr_indices[k]
54
+ col_order[pos[c]] = k
55
+ pos[c] += 1
56
+
57
+ return col_indptr, col_order
58
+
59
+
60
+ @nb.njit
61
+ def _sparse_mwu_column(vals_a, vals_b, n_a, n_b, use_continuity, alternative):
62
+ """Compute Mann-Whitney U for a single gene from two groups' nonzero values.
63
+
64
+ Zeros are treated analytically — they form a contiguous block at the
65
+ start of the sorted order (requires non-negative data).
66
+
67
+ Parameters
68
+ ----------
69
+ vals_a : float64 1-D array
70
+ Nonzero values for this gene in group A.
71
+ vals_b : float64 1-D array
72
+ Nonzero values for this gene in group B.
73
+ n_a, n_b : int
74
+ Total number of cells in group A and group B (including zeros).
75
+ use_continuity : bool
76
+ alternative : int (0=two-sided, 1=less, 2=greater)
77
+
78
+ Returns
79
+ -------
80
+ U1 : float64
81
+ pvalue : float64
82
+ """
83
+ n = n_a + n_b
84
+ nnz_a = vals_a.shape[0]
85
+ nnz_b = vals_b.shape[0]
86
+ nnz = nnz_a + nnz_b
87
+ nz = n - nnz # total implicit zeros
88
+ nz_a = n_a - nnz_a # zeros in group A
89
+
90
+ # --- All zeros: no evidence of difference ---
91
+ if nnz == 0:
92
+ return n_a * n_b / 2.0, 1.0
93
+
94
+ # --- Merge nonzero values into a single array with group tags ---
95
+ all_vals = np.empty(nnz, dtype=np.float64)
96
+ is_a = np.empty(nnz, dtype=np.int8)
97
+ for k in range(nnz_a):
98
+ all_vals[k] = vals_a[k]
99
+ is_a[k] = 1
100
+ for k in range(nnz_b):
101
+ all_vals[nnz_a + k] = vals_b[k]
102
+ is_a[nnz_a + k] = 0
103
+
104
+ # --- Sort nonzero values ---
105
+ sort_idx = np.argsort(all_vals)
106
+
107
+ # --- Walk sorted nonzeros: compute local ranks, tie correction,
108
+ # and sum of global ranks for group A nonzeros ---
109
+ tie_term_nz = 0.0
110
+ sum_global_ranks_a = 0.0
111
+
112
+ i = 0
113
+ while i < nnz:
114
+ j = i
115
+ while j < nnz - 1 and all_vals[sort_idx[j]] == all_vals[sort_idx[j + 1]]:
116
+ j += 1
117
+
118
+ tie_count = float(j - i + 1)
119
+ tie_term_nz += tie_count * tie_count * tie_count - tie_count
120
+
121
+ # Local average rank (1-based among nonzeros), shifted to global
122
+ local_avg_rank = (i + j) / 2.0 + 1.0
123
+ global_avg_rank = nz + local_avg_rank
124
+
125
+ for k in range(i, j + 1):
126
+ if is_a[sort_idx[k]] == 1:
127
+ sum_global_ranks_a += global_avg_rank
128
+
129
+ i = j + 1
130
+
131
+ # --- R1: total rank sum for group A ---
132
+ zero_avg_rank = (nz + 1.0) / 2.0
133
+ R1 = nz_a * zero_avg_rank + sum_global_ranks_a
134
+
135
+ # --- U statistic ---
136
+ U1 = R1 - n_a * (n_a + 1.0) / 2.0
137
+ U2 = n_a * n_b - U1
138
+
139
+ # --- Tie correction (zero block + nonzero tie groups) ---
140
+ tie_term = (float(nz) * float(nz) * float(nz) - float(nz)) + tie_term_nz
141
+
142
+ # --- Variance and z-score ---
143
+ mu = n_a * n_b / 2.0
144
+ denom = float(n) * float(n - 1)
145
+ var_inner = (n + 1.0) - tie_term / denom
146
+ s_sq = n_a * n_b / 12.0 * var_inner
147
+
148
+ if s_sq <= 0.0:
149
+ return U1, 1.0
150
+
151
+ s = math.sqrt(s_sq)
152
+
153
+ if alternative == GREATER:
154
+ U = U1
155
+ f = 1.0
156
+ elif alternative == LESS:
157
+ U = U2
158
+ f = 1.0
159
+ else: # TWO_SIDED
160
+ U = max(U1, U2)
161
+ f = 2.0
162
+
163
+ numerator = U - mu
164
+ if use_continuity:
165
+ numerator -= 0.5
166
+
167
+ z = numerator / s
168
+ p = _ndtr(-z) * f
169
+
170
+ if p > 1.0:
171
+ p = 1.0
172
+ if p < 0.0:
173
+ p = 0.0
174
+
175
+ return U1, p
176
+
177
+
178
+ @nb.njit
179
+ def _gather_col_vals(csr_data, col_order, start, end):
180
+ """Gather nonzero values for a single column from CSR data via col_order."""
181
+ n = end - start
182
+ vals = np.empty(n, dtype=np.float64)
183
+ for k in range(n):
184
+ vals[k] = csr_data[col_order[start + k]]
185
+ return vals
186
+
187
+
188
+ @nb.njit(parallel=True)
189
+ def _sparse_mwu_batch(
190
+ data_a,
191
+ col_indptr_a,
192
+ col_order_a,
193
+ n_a,
194
+ data_b,
195
+ col_indptr_b,
196
+ col_order_b,
197
+ n_b,
198
+ use_continuity,
199
+ alternative,
200
+ ):
201
+ """Run sparse MWU test on each gene using two CSR matrices' column indices.
202
+
203
+ Parameters
204
+ ----------
205
+ data_a : float64 1-D array — CSR data for group A
206
+ col_indptr_a : int64 1-D array (n_genes + 1) — column pointers for A
207
+ col_order_a : int64 1-D array (nnz_a) — column permutation for A
208
+ n_a : int — total rows in A
209
+ data_b : float64 1-D array — CSR data for group B
210
+ col_indptr_b : int64 1-D array (n_genes + 1) — column pointers for B
211
+ col_order_b : int64 1-D array (nnz_b) — column permutation for B
212
+ n_b : int — total rows in B
213
+ use_continuity : bool
214
+ alternative : int
215
+
216
+ Returns
217
+ -------
218
+ U_out : float64 array (n_genes,)
219
+ p_out : float64 array (n_genes,)
220
+ """
221
+ n_genes = col_indptr_a.shape[0] - 1
222
+ U_out = np.empty(n_genes, dtype=np.float64)
223
+ p_out = np.empty(n_genes, dtype=np.float64)
224
+
225
+ for j in nb.prange(n_genes): # type: ignore
226
+ vals_a = _gather_col_vals(
227
+ data_a, col_order_a, col_indptr_a[j], col_indptr_a[j + 1]
228
+ )
229
+ vals_b = _gather_col_vals(
230
+ data_b, col_order_b, col_indptr_b[j], col_indptr_b[j + 1]
231
+ )
232
+ U_out[j], p_out[j] = _sparse_mwu_column(
233
+ vals_a, vals_b, n_a, n_b, use_continuity, alternative
234
+ )
235
+
236
+ return U_out, p_out
File without changes