Skip to content

Commit a235f3f

Browse files
authored
Merge pull request #125 from maximtrp/fix/pandas-nullable-numeric-boundaries
Fix nullable numeric inputs on older pandas
2 parents dcd4265 + 201c5a0 commit a235f3f

4 files changed

Lines changed: 141 additions & 39 deletions

File tree

‎.github/workflows/package-pull.yml‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
run: |
3131
pytest .
3232
33-
pandas-categorical-compatibility:
33+
pandas-compatibility:
3434
runs-on: ubuntu-latest
3535
steps:
3636
- uses: actions/checkout@v4
@@ -43,10 +43,6 @@ jobs:
4343
run: |
4444
python -m pip install --upgrade pip
4545
python -m pip install ".[test]" "numpy<2" "pandas==1.5.3"
46-
- name: Testing categorical and duplicate-index inputs with pandas 1.5
46+
- name: Testing full suite with pandas 1.5
4747
run: |
48-
pytest \
49-
tests/test_known_input_regressions.py::test_tukey_categorical_group_order_matches_scipy \
50-
tests/test_known_input_regressions.py::test_tukey_duplicate_index_matches_scipy \
51-
tests/test_posthocs.py::TestPosthocs::test_posthoc_tukey_hsd_repeated_index \
52-
tests/test_independent_input_paths.py::test_unused_categories_are_not_treatments_and_ordered_categories_align_values
48+
pytest .

‎scikit_posthocs/_posthocs.py‎

Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,17 @@
88
import scipy.stats as ss
99
from scipy import integrate, special
1010
from statsmodels.stats.multitest import multipletests
11-
from pandas import DataFrame, Series, MultiIndex
11+
from pandas import DataFrame, Series, MultiIndex, concat
12+
13+
14+
def __numeric_array(values: Series) -> np.ndarray:
15+
"""Expose nullable numeric data without object arrays or integer rounding."""
16+
dtype = getattr(values.dtype, "numpy_dtype", None)
17+
if dtype is None:
18+
return values.to_numpy()
19+
if dtype.kind in "biu" and values.hasnans:
20+
dtype = float
21+
return values.to_numpy(dtype=dtype, na_value=np.nan)
1222

1323

1424
def __validate_n_permutations(value: int, name: str) -> int:
@@ -153,6 +163,14 @@ def __convert_to_block_df(
153163
x = x.reset_index().melt(
154164
id_vars=new_block_id_col, var_name=new_group_col, value_name=new_y_col
155165
)
166+
# pandas 1.5 melt loses nullable measurement dtypes. Concatenating the
167+
# original columns preserves their common dtype and missing values.
168+
if x[new_y_col].dtype == object and any(
169+
getattr(dtype, "numpy_dtype", None) is not None for dtype in a.dtypes
170+
):
171+
x[new_y_col] = concat(
172+
[a.iloc[:, column] for column in range(a.shape[1])], ignore_index=True
173+
)
156174
x[new_block_col] = a.index.take(x[new_block_id_col]).to_numpy()
157175
x[new_group_col] = a.columns.take(x[new_group_col]).to_numpy()
158176

@@ -1015,7 +1033,7 @@ def posthoc_npm_test(
10151033
k = groups.size
10161034
n = x.shape[0]
10171035
sigma = np.sqrt(n * (n + 1) / 12.0)
1018-
values = x[_val_col].to_numpy()
1036+
values = __numeric_array(x[_val_col])
10191037
group_values = x[_group_col].to_numpy()
10201038
group_codes = np.empty(n, dtype=int)
10211039
for code, group in enumerate(groups):
@@ -1558,7 +1576,7 @@ def posthoc_anderson(
15581576
groups = x[_group_col].unique()
15591577
k = groups.size
15601578
x_grouped = x.groupby(_group_col, observed=True)[_val_col]
1561-
samples = [x_grouped.get_group(group).dropna().to_numpy() for group in groups]
1579+
samples = [__numeric_array(x_grouped.get_group(group).dropna()) for group in groups]
15621580
vs = np.zeros((k, k), dtype=float)
15631581
combs = it.combinations(range(k), 2)
15641582

@@ -2061,7 +2079,7 @@ def posthoc_ttest(
20612079
0.047356
20622080
"""
20632081
x, _val_col, _group_col = __convert_to_df(a, val_col, group_col)
2064-
if not np.isfinite(x[_val_col].to_numpy(dtype=float)).all():
2082+
if not np.isfinite(x[_val_col].to_numpy(dtype=float, na_value=np.nan)).all():
20652083
raise ValueError(
20662084
"All observations must be finite; remove or impute missing values before calling "
20672085
"posthoc_ttest"
@@ -2071,7 +2089,7 @@ def posthoc_ttest(
20712089
groups = x[_group_col].unique()
20722090
k = groups.size
20732091
xg = x.groupby(by=_group_col, observed=True)[_val_col]
2074-
samples = [xg.get_group(group).to_numpy() for group in groups]
2092+
samples = [__numeric_array(xg.get_group(group)) for group in groups]
20752093

20762094
vs = np.zeros((k, k), dtype=float)
20772095
tri_upper = np.triu_indices(vs.shape[0], 1)
@@ -2158,7 +2176,7 @@ def posthoc_tukey_hsd(
21582176
# Older pandas iterates ordered categoricals in category order even with
21592177
# sort=False. Extract samples in the same order as the output labels.
21602178
xg = x.groupby(_group_col, observed=True, sort=False)[_val_col]
2161-
results = ss.tukey_hsd(*[xg.get_group(group).to_numpy() for group in groups])
2179+
results = ss.tukey_hsd(*[__numeric_array(xg.get_group(group)) for group in groups])
21622180

21632181
return DataFrame(results.pvalue, index=groups, columns=groups)
21642182

@@ -2240,7 +2258,7 @@ def posthoc_mannwhitney(
22402258
x_len = groups.size
22412259
vs = np.zeros((x_len, x_len))
22422260
xg = x.groupby(_group_col, observed=True)[_val_col]
2243-
samples = [xg.get_group(group).to_numpy() for group in groups]
2261+
samples = [__numeric_array(xg.get_group(group)) for group in groups]
22442262
tri_upper = np.triu_indices(vs.shape[0], 1)
22452263
tri_lower = np.tril_indices(vs.shape[0], -1)
22462264
vs[:, :] = 0
@@ -2363,7 +2381,7 @@ def posthoc_wilcoxon(
23632381
x_len = groups.size
23642382
vs = np.zeros((x_len, x_len))
23652383
xg = x.groupby(_group_col, observed=True)[_val_col]
2366-
samples = [xg.get_group(group).to_numpy() for group in groups]
2384+
samples = [__numeric_array(xg.get_group(group)) for group in groups]
23672385
tri_upper = np.triu_indices(vs.shape[0], 1)
23682386
tri_lower = np.tril_indices(vs.shape[0], -1)
23692387
vs[:, :] = 0
@@ -2461,8 +2479,8 @@ def posthoc_scheffe(
24612479
tri_lower = np.tril_indices(vs.shape[0], -1)
24622480
vs[:, :] = 0
24632481

2464-
counts = ni.reindex(groups).to_numpy()
2465-
means = xi.reindex(groups).to_numpy()
2482+
counts = __numeric_array(ni.reindex(groups))
2483+
means = __numeric_array(xi.reindex(groups))
24662484
dif = means[tri_upper[0]] - means[tri_upper[1]]
24672485
A = sin * (1.0 / counts[tri_upper[0]] + 1.0 / counts[tri_upper[1]]) * (groups.size - 1.0)
24682486
vs[tri_upper] = dif**2.0 / A
@@ -2549,9 +2567,9 @@ def posthoc_tamhane(
25492567
tri_lower = np.tril_indices(vs.shape[0], -1)
25502568
vs[:, :] = 0
25512569

2552-
counts = ni.reindex(groups).to_numpy()
2553-
means = xi.reindex(groups).to_numpy()
2554-
variances = si.reindex(groups).to_numpy()
2570+
counts = __numeric_array(ni.reindex(groups))
2571+
means = __numeric_array(xi.reindex(groups))
2572+
variances = __numeric_array(si.reindex(groups))
25552573
i, j = tri_upper
25562574
scaled_variances = variances / counts
25572575
A = scaled_variances[i] + scaled_variances[j]
@@ -2667,8 +2685,8 @@ def posthoc_tukey(
26672685
vs = np.zeros((groups.size, groups.size), dtype=float)
26682686
tri_upper = np.triu_indices(vs.shape[0], 1)
26692687
tri_lower = np.tril_indices(vs.shape[0], -1)
2670-
counts = ni.reindex(groups).to_numpy()
2671-
means = xi.reindex(groups).to_numpy()
2688+
counts = __numeric_array(ni.reindex(groups))
2689+
means = __numeric_array(xi.reindex(groups))
26722690
scale = sin * 0.5 * (1.0 / counts[tri_upper[0]] + 1.0 / counts[tri_upper[1]])
26732691
q_values = (means[tri_upper[0]] - means[tri_upper[1]]) / np.sqrt(scale)
26742692
vs[tri_upper] = ss.studentized_range.sf(np.abs(q_values), groups.size, n - groups.size)
@@ -2743,7 +2761,7 @@ def posthoc_dscf(
27432761
x = x.sort_values(by=[_group_col], ascending=True) if sort else x
27442762
groups = x[_group_col].unique()
27452763
x_grouped = x.groupby(_group_col, observed=True)[_val_col]
2746-
samples = [x_grouped.get_group(group).dropna().to_numpy() for group in groups]
2764+
samples = [__numeric_array(x_grouped.get_group(group).dropna()) for group in groups]
27472765
k = groups.size
27482766

27492767
def compare(i, j):
@@ -2856,9 +2874,9 @@ def posthoc_games_howell(
28562874
tri_lower = np.tril_indices(vs.shape[0], -1)
28572875
vs[:, :] = 0
28582876

2859-
counts = ni.reindex(groups).to_numpy()
2860-
means = xi.reindex(groups).to_numpy()
2861-
variances = si.reindex(groups).to_numpy()
2877+
counts = __numeric_array(ni.reindex(groups))
2878+
means = __numeric_array(xi.reindex(groups))
2879+
variances = __numeric_array(si.reindex(groups))
28622880
i, j = tri_upper
28632881
A = variances[i] / counts[i] + variances[j] / counts[j]
28642882
q_values = (means[i] - means[j]) / np.sqrt(A) * np.sqrt(2.0)
@@ -2982,9 +3000,9 @@ def posthoc_dunnett_t3(
29823000
tri_lower = np.tril_indices(vs.shape[0], -1)
29833001
vs[:, :] = 0
29843002

2985-
counts = ni.reindex(groups).to_numpy()
2986-
means = xi.reindex(groups).to_numpy()
2987-
variances = si.reindex(groups).to_numpy()
3003+
counts = __numeric_array(ni.reindex(groups))
3004+
means = __numeric_array(xi.reindex(groups))
3005+
variances = __numeric_array(si.reindex(groups))
29883006
i, j = tri_upper
29893007
A = variances[i] / counts[i] + variances[j] / counts[j]
29903008
t_values = (means[i] - means[j]) / np.sqrt(A)
@@ -3076,8 +3094,8 @@ def posthoc_lsd(
30763094
tri_lower = np.tril_indices(vs.shape[0], -1)
30773095
vs[:, :] = 0
30783096

3079-
counts = ni.reindex(groups).to_numpy()
3080-
means = xi.reindex(groups).to_numpy()
3097+
counts = __numeric_array(ni.reindex(groups))
3098+
means = __numeric_array(xi.reindex(groups))
30813099
i, j = tri_upper
30823100
A = s2in * (1.0 / counts[i] + 1.0 / counts[j])
30833101
t_values = (means[i] - means[j]) / np.sqrt(A)
@@ -3166,8 +3184,8 @@ def posthoc_snk(
31663184
df = n - k
31673185

31683186
# rank position of each group when means are sorted in decreasing order
3169-
counts = ni.reindex(groups).to_numpy()
3170-
means = xi.reindex(groups).to_numpy()
3187+
counts = __numeric_array(ni.reindex(groups))
3188+
means = __numeric_array(xi.reindex(groups))
31713189
order = means.argsort()[::-1]
31723190
rank_pos = np.empty(k, dtype=int)
31733191
rank_pos[order] = np.arange(k)
@@ -3257,8 +3275,7 @@ def posthoc_duncan(
32573275
df = n - k
32583276
r = k / np.sum(1.0 / ni)
32593277

3260-
counts = ni.reindex(groups).to_numpy()
3261-
means = xi.reindex(groups).to_numpy()
3278+
means = __numeric_array(xi.reindex(groups))
32623279
order = means.argsort()[::-1]
32633280
rank_pos = np.empty(k, dtype=int)
32643281
rank_pos[order] = np.arange(k)

‎tests/test_input_conversion_contracts.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,19 +70,20 @@ def test_optimized_block_gate_rejects_inputs_requiring_conversion(data, melted,
7070
np.testing.assert_equal(result is None, True)
7171

7272

73-
def test_block_converter_uses_row_ids_without_losing_measurement_dtype():
73+
@pytest.mark.parametrize("dtype", ["float32", "float64", "Float32", "Float64", "Int64", "UInt64"])
74+
def test_block_converter_uses_row_ids_without_losing_measurement_dtype(dtype):
7475
data = pd.DataFrame(
7576
{
76-
"y": pd.array([1, None, 3], dtype="Float64"),
77-
"block_ids": pd.array([4, 5, 6], dtype="Float64"),
77+
"y": pd.array([1, None, 3], dtype=dtype),
78+
"block_ids": pd.array([4, 5, 6], dtype=dtype),
7879
}
7980
)
8081
data.index = ["subject", "subject", "other"]
8182
original = data.copy(deep=True)
8283

8384
actual, value_col, group_col, block_col, block_id_col = sp.__convert_to_block_df(data)
8485

85-
np.testing.assert_equal(actual[value_col].dtype, pd.Float64Dtype())
86+
np.testing.assert_equal(actual[value_col].dtype, pd.api.types.pandas_dtype(dtype))
8687
np.testing.assert_equal(len(actual), data.size)
8788
np.testing.assert_array_equal(actual[group_col], ["y"] * 3 + ["block_ids"] * 3)
8889
np.testing.assert_array_equal(actual[block_id_col], [0, 1, 2, 0, 1, 2])
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import numpy as np
2+
import pandas as pd
3+
import pytest
4+
from scipy import stats
5+
6+
import scikit_posthocs as sp
7+
import scikit_posthocs._posthocs as posthocs
8+
9+
10+
@pytest.mark.parametrize("dtype", ["Float32", "Float64", "Int64", "UInt64", "boolean"])
11+
@pytest.mark.parametrize("missing", [False, True])
12+
def test_nullable_numeric_array_has_native_dtype_and_preserves_missing_values(dtype, missing):
13+
values = [True, False, True] if dtype == "boolean" else [1, 2, 3]
14+
if missing:
15+
values[1] = None
16+
data = pd.Series(values, dtype=dtype)
17+
original = data.copy(deep=True)
18+
expected_dtype = data.dtype.numpy_dtype
19+
if missing and expected_dtype.kind in "biu":
20+
expected_dtype = np.dtype(float)
21+
expected = np.array(
22+
[np.nan if value is None else value for value in values], dtype=expected_dtype
23+
)
24+
25+
actual = posthocs.__numeric_array(data)
26+
27+
np.testing.assert_equal(actual.dtype, expected_dtype)
28+
np.testing.assert_array_equal(actual, expected)
29+
pd.testing.assert_series_equal(data, original)
30+
31+
32+
@pytest.mark.parametrize("dtype", ["float32", "float64", "int64", "uint64", "bool"])
33+
def test_native_numeric_array_does_not_change_dtype(dtype):
34+
data = pd.Series([0, 1, 0], dtype=dtype)
35+
36+
actual = posthocs.__numeric_array(data)
37+
38+
np.testing.assert_equal(actual.dtype, data.dtype)
39+
np.testing.assert_array_equal(actual, data.to_numpy())
40+
41+
42+
@pytest.mark.parametrize("dtype", ["Int64", "UInt64"])
43+
@pytest.mark.parametrize("sort", [False, True])
44+
def test_nullable_large_integers_do_not_gain_ties_from_float_conversion(dtype, sort):
45+
offset = 2**53
46+
first = np.array([offset, offset + 1, offset + 4], dtype=dtype.lower())
47+
second = np.array([offset + 2, offset + 3, offset + 5], dtype=dtype.lower())
48+
data = pd.DataFrame(
49+
{
50+
"value": pd.array(np.concatenate([first, second]), dtype=dtype),
51+
"group": ["z"] * 3 + ["a"] * 3,
52+
}
53+
)
54+
original = data.copy(deep=True)
55+
56+
actual = sp.posthoc_mannwhitney(data, val_col="value", group_col="group", sort=sort)
57+
58+
np.testing.assert_allclose(actual.loc["z", "a"], stats.mannwhitneyu(first, second).pvalue)
59+
native = posthocs.__numeric_array(data["value"])
60+
np.testing.assert_equal(native.dtype, np.dtype(dtype.lower()))
61+
np.testing.assert_array_equal(native, np.concatenate([first, second]))
62+
pd.testing.assert_frame_equal(data, original)
63+
64+
65+
def test_nullable_missing_ttest_observations_keep_finite_input_error():
66+
data = pd.DataFrame(
67+
{"value": pd.array([1, None, 3, 5, 7, 9], dtype="Float64"), "group": ["a"] * 3 + ["b"] * 3}
68+
)
69+
70+
with pytest.raises(ValueError, match="All observations must be finite"):
71+
sp.posthoc_ttest(data, val_col="value", group_col="group")
72+
73+
74+
@pytest.mark.parametrize("dtype", ["Float64", "Int64"])
75+
def test_nullable_wilcoxon_matches_scipy_and_rejects_missing_pairs(dtype):
76+
first, second = [1, 2, 4, 5, 8], [2, 4, 7, 8, 12]
77+
data = pd.DataFrame(
78+
{"value": pd.array(first + second, dtype=dtype), "group": ["a"] * 5 + ["b"] * 5}
79+
)
80+
original = data.copy(deep=True)
81+
82+
actual = sp.posthoc_wilcoxon(data, val_col="value", group_col="group")
83+
84+
np.testing.assert_allclose(actual.loc["a", "b"], stats.wilcoxon(first, second).pvalue)
85+
pd.testing.assert_frame_equal(data, original)
86+
data.loc[0, "value"] = pd.NA
87+
with pytest.raises(ValueError, match="missing paired observations"):
88+
sp.posthoc_wilcoxon(data, val_col="value", group_col="group")

0 commit comments

Comments
 (0)