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.
- numba_mwu-0.1.1/PKG-INFO +170 -0
- numba_mwu-0.1.1/README.md +159 -0
- numba_mwu-0.1.1/pyproject.toml +25 -0
- numba_mwu-0.1.1/src/numba_mwu/__init__.py +281 -0
- numba_mwu-0.1.1/src/numba_mwu/_batch.py +60 -0
- numba_mwu-0.1.1/src/numba_mwu/_core.py +137 -0
- numba_mwu-0.1.1/src/numba_mwu/_sparse.py +236 -0
- numba_mwu-0.1.1/src/numba_mwu/py.typed +0 -0
numba_mwu-0.1.1/PKG-INFO
ADDED
|
@@ -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
|