microdf-python 1.3.4__tar.gz → 1.3.5__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.
- {microdf_python-1.3.4 → microdf_python-1.3.5}/PKG-INFO +1 -1
- {microdf_python-1.3.4 → microdf_python-1.3.5}/microdf/microseries.py +52 -4
- microdf_python-1.3.5/microdf/tests/test_quantile_missing_values.py +134 -0
- {microdf_python-1.3.4 → microdf_python-1.3.5}/microdf_python.egg-info/PKG-INFO +1 -1
- {microdf_python-1.3.4 → microdf_python-1.3.5}/microdf_python.egg-info/SOURCES.txt +1 -0
- {microdf_python-1.3.4 → microdf_python-1.3.5}/pyproject.toml +1 -1
- {microdf_python-1.3.4 → microdf_python-1.3.5}/LICENSE +0 -0
- {microdf_python-1.3.4 → microdf_python-1.3.5}/README.md +0 -0
- {microdf_python-1.3.4 → microdf_python-1.3.5}/microdf/__init__.py +0 -0
- {microdf_python-1.3.4 → microdf_python-1.3.5}/microdf/microdataframe.py +0 -0
- {microdf_python-1.3.4 → microdf_python-1.3.5}/microdf/tests/conftest.py +0 -0
- {microdf_python-1.3.4 → microdf_python-1.3.5}/microdf/tests/test_dataframe_weight_storage.py +0 -0
- {microdf_python-1.3.4 → microdf_python-1.3.5}/microdf/tests/test_microseries_dataframe.py +0 -0
- {microdf_python-1.3.4 → microdf_python-1.3.5}/microdf/tests/test_nullify_weights_index.py +0 -0
- {microdf_python-1.3.4 → microdf_python-1.3.5}/microdf/tests/test_pandas3_compatibility.py +0 -0
- {microdf_python-1.3.4 → microdf_python-1.3.5}/microdf_python.egg-info/dependency_links.txt +0 -0
- {microdf_python-1.3.4 → microdf_python-1.3.5}/microdf_python.egg-info/requires.txt +0 -0
- {microdf_python-1.3.4 → microdf_python-1.3.5}/microdf_python.egg-info/top_level.txt +0 -0
- {microdf_python-1.3.4 → microdf_python-1.3.5}/setup.cfg +0 -0
|
@@ -310,7 +310,7 @@ class MicroSeries(pd.Series):
|
|
|
310
310
|
)
|
|
311
311
|
return super().corr(other, *args, **kwargs)
|
|
312
312
|
|
|
313
|
-
def quantile(self, q: np.array) -> pd.Series:
|
|
313
|
+
def quantile(self, q: np.array, skipna: bool = True) -> pd.Series:
|
|
314
314
|
"""Calculates weighted quantiles of the MicroSeries.
|
|
315
315
|
|
|
316
316
|
Uses the inverse CDF method: the q-th quantile is the smallest
|
|
@@ -319,6 +319,11 @@ class MicroSeries(pd.Series):
|
|
|
319
319
|
|
|
320
320
|
:param q: Quantile(s) to calculate, must be in [0, 1].
|
|
321
321
|
:type q: float or np.array
|
|
322
|
+
:param skipna: Exclude NaN values (default True). NaN sorts to the
|
|
323
|
+
end of the array, so leaving NaN rows in would let their weight
|
|
324
|
+
inflate the cumulative distribution and push the cutoff upward.
|
|
325
|
+
If False, NaN is returned whenever any value is NaN.
|
|
326
|
+
:type skipna: bool
|
|
322
327
|
|
|
323
328
|
:return: Weighted quantile value(s).
|
|
324
329
|
:rtype: float or pd.Series
|
|
@@ -329,12 +334,22 @@ class MicroSeries(pd.Series):
|
|
|
329
334
|
assert np.all(quantiles >= 0) and np.all(quantiles <= 1), (
|
|
330
335
|
"quantiles should be in [0, 1]"
|
|
331
336
|
)
|
|
337
|
+
na_mask = pd.isna(values)
|
|
338
|
+
if not skipna and na_mask.any():
|
|
339
|
+
return (
|
|
340
|
+
np.nan
|
|
341
|
+
if np.array(q).shape == ()
|
|
342
|
+
else pd.Series(np.full(len(quantiles), np.nan), index=quantiles)
|
|
343
|
+
)
|
|
332
344
|
# Drop zero-weight rows before sorting. Without this, q=0 (and
|
|
333
345
|
# internal plateaus of zero weight) picked a value with 0 weight
|
|
334
346
|
# that should have been skipped by the inverse CDF. E.g.
|
|
335
347
|
# MicroSeries([10, 20, 30], weights=[0, 1, 1]).quantile(0)
|
|
336
348
|
# returned 10 instead of 20.
|
|
337
|
-
|
|
349
|
+
# Drop NaN rows for the same reason: NaN sorts last, so its weight
|
|
350
|
+
# would inflate the cumulative distribution and push the cutoff up
|
|
351
|
+
# (median of [1, nan, 3] returned 3.0 instead of 1.0).
|
|
352
|
+
nonzero = (sample_weight > 0) & ~na_mask
|
|
338
353
|
if not nonzero.any():
|
|
339
354
|
return (
|
|
340
355
|
np.nan
|
|
@@ -359,13 +374,15 @@ class MicroSeries(pd.Series):
|
|
|
359
374
|
return pd.Series(result, index=quantiles)
|
|
360
375
|
|
|
361
376
|
@scalar_function
|
|
362
|
-
def median(self) -> float:
|
|
377
|
+
def median(self, skipna: bool = True) -> float:
|
|
363
378
|
"""Calculates the weighted median of the MicroSeries.
|
|
364
379
|
|
|
380
|
+
:param skipna: Exclude NaN values (default True).
|
|
381
|
+
:type skipna: bool
|
|
365
382
|
:returns: The weighted median of a DataFrame's column.
|
|
366
383
|
:rtype: float
|
|
367
384
|
"""
|
|
368
|
-
return self.quantile(0.5)
|
|
385
|
+
return self.quantile(0.5, skipna=skipna)
|
|
369
386
|
|
|
370
387
|
@scalar_function
|
|
371
388
|
def gini(self, negatives: Optional[str] = None) -> float:
|
|
@@ -873,6 +890,37 @@ class MicroSeriesGroupBy(pd.core.groupby.generic.SeriesGroupBy):
|
|
|
873
890
|
or name in MicroSeries.AGNOSTIC_FUNCTIONS
|
|
874
891
|
and is_array
|
|
875
892
|
):
|
|
893
|
+
if name in MicroSeries.AGNOSTIC_FUNCTIONS and not df.empty:
|
|
894
|
+
# Concatenate values without keys: concat rejects missing
|
|
895
|
+
# MultiIndex keys even when groupby(dropna=False) retains
|
|
896
|
+
# them. Reuse the grouping levels and codes so missing
|
|
897
|
+
# labels keep the same representation as scalar results.
|
|
898
|
+
results = [
|
|
899
|
+
via_micro_series(row, *args, **kwargs)
|
|
900
|
+
for _, row in df.iterrows()
|
|
901
|
+
]
|
|
902
|
+
result = pd.concat(results)
|
|
903
|
+
group_index = (
|
|
904
|
+
df.index
|
|
905
|
+
if isinstance(df.index, pd.MultiIndex)
|
|
906
|
+
else pd.MultiIndex.from_arrays([df.index])
|
|
907
|
+
)
|
|
908
|
+
quantile_codes, quantile_levels = result.index.factorize(
|
|
909
|
+
sort=False
|
|
910
|
+
)
|
|
911
|
+
result.index = pd.MultiIndex(
|
|
912
|
+
levels=[*group_index.levels, quantile_levels],
|
|
913
|
+
codes=[
|
|
914
|
+
codes.repeat(len(results[0]))
|
|
915
|
+
for codes in group_index.codes
|
|
916
|
+
]
|
|
917
|
+
+ [quantile_codes],
|
|
918
|
+
names=[*df.index.names, result.index.name],
|
|
919
|
+
# Existing group codes are valid; checking would
|
|
920
|
+
# rewrite their retained missing labels to -1.
|
|
921
|
+
verify_integrity=False,
|
|
922
|
+
)
|
|
923
|
+
return result
|
|
876
924
|
result = df.apply(
|
|
877
925
|
lambda row: via_micro_series(row, *args, **kwargs),
|
|
878
926
|
axis=1,
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import microdf as mdf
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_quantile_skips_nan():
|
|
8
|
+
"""NaN weight must not inflate the cumulative distribution.
|
|
9
|
+
|
|
10
|
+
Dropping a NaN row should give the same answer as never having had
|
|
11
|
+
it: the inverse-CDF quantile of [1, nan, 3] equals that of [1, 3].
|
|
12
|
+
"""
|
|
13
|
+
with_nan = mdf.MicroSeries([1.0, np.nan, 3.0], weights=[1, 1, 1])
|
|
14
|
+
without_nan = mdf.MicroSeries([1.0, 3.0], weights=[1, 1])
|
|
15
|
+
assert with_nan.median() == without_nan.median()
|
|
16
|
+
assert with_nan.quantile(0.5) == without_nan.quantile(0.5)
|
|
17
|
+
|
|
18
|
+
q = [0.25, 0.5, 0.75]
|
|
19
|
+
np.testing.assert_array_equal(
|
|
20
|
+
mdf.MicroSeries([1.0, np.nan, 3.0, 5.0], weights=[1, 1, 1, 1]).quantile(q),
|
|
21
|
+
mdf.MicroSeries([1.0, 3.0, 5.0], weights=[1, 1, 1]).quantile(q),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_quantile_skipna_false_propagates_nan():
|
|
26
|
+
"""Skipna=False returns NaN when any value is NaN, like mean/var."""
|
|
27
|
+
s = mdf.MicroSeries([1.0, np.nan, 3.0], weights=[1, 1, 1])
|
|
28
|
+
assert np.isnan(s.quantile(0.5, skipna=False))
|
|
29
|
+
assert np.isnan(s.median(skipna=False))
|
|
30
|
+
assert s.quantile([0.25, 0.75], skipna=False).isna().all()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_quantile_all_nan_returns_nan():
|
|
34
|
+
s = mdf.MicroSeries([np.nan, np.nan], weights=[1, 1])
|
|
35
|
+
assert np.isnan(s.median())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.parametrize("skipna", [True, False])
|
|
39
|
+
@pytest.mark.parametrize("q", [-0.1, 1.1, [0.5, 1.1]])
|
|
40
|
+
def test_quantile_validates_bounds_with_missing_values(q, skipna):
|
|
41
|
+
series = mdf.MicroSeries([1.0, np.nan, 3.0], weights=[1, 7, 1])
|
|
42
|
+
with pytest.raises(AssertionError, match="quantiles should be in"):
|
|
43
|
+
series.quantile(q, skipna=skipna)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.parametrize("skipna", [True, False])
|
|
47
|
+
@pytest.mark.parametrize("multiple_keys", [False, True])
|
|
48
|
+
def test_grouped_quantiles_preserve_missing_groups(skipna, multiple_keys):
|
|
49
|
+
series = mdf.MicroSeries(
|
|
50
|
+
[1.0, np.nan, 3.0, 5.0, np.nan, np.nan], weights=[1, 1, 1, 1, 1, 1]
|
|
51
|
+
)
|
|
52
|
+
groups = ["a", "a", "b", "b", "c", "c"]
|
|
53
|
+
keys = [groups, [1, 1, 2, 2, 3, 3]] if multiple_keys else groups
|
|
54
|
+
grouped = series.groupby(keys)
|
|
55
|
+
quantiles = [0.25, 0.75]
|
|
56
|
+
result = grouped.quantile(quantiles, skipna=skipna)
|
|
57
|
+
# Scalar calls retain every group. Vector calls must retain the same
|
|
58
|
+
# groups, including the partial-NaN and all-NaN groups.
|
|
59
|
+
for quantile in quantiles:
|
|
60
|
+
pd.testing.assert_series_equal(
|
|
61
|
+
result.xs(quantile, level=-1),
|
|
62
|
+
grouped.quantile(quantile, skipna=skipna),
|
|
63
|
+
)
|
|
64
|
+
first_group = 1.0 if skipna else np.nan
|
|
65
|
+
np.testing.assert_allclose(
|
|
66
|
+
result.to_numpy(),
|
|
67
|
+
[first_group, first_group, 3.0, 5.0, np.nan, np.nan],
|
|
68
|
+
equal_nan=True,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_grouped_quantiles_preserve_repeated_requests():
|
|
73
|
+
series = mdf.MicroSeries([1.0, np.nan, 3.0, 5.0], weights=[1, 1, 1, 1])
|
|
74
|
+
result = series.groupby(["a", "a", "b", "b"]).quantile([0.5, 0.5], skipna=False)
|
|
75
|
+
assert result.index.tolist() == [("a", 0.5), ("a", 0.5), ("b", 0.5), ("b", 0.5)]
|
|
76
|
+
np.testing.assert_allclose(
|
|
77
|
+
result.to_numpy(), [np.nan, np.nan, 3.0, 3.0], equal_nan=True
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.parametrize("quantiles", [[0.75, 0.25], [0.5, 0.5], []])
|
|
82
|
+
@pytest.mark.parametrize("skipna", [True, False])
|
|
83
|
+
@pytest.mark.parametrize("sort", [True, False])
|
|
84
|
+
def test_grouped_quantiles_preserve_missing_multiple_keys(quantiles, skipna, sort):
|
|
85
|
+
"""Missing group keys survive alongside missing values and repeated q."""
|
|
86
|
+
frame = mdf.MicroDataFrame(
|
|
87
|
+
{
|
|
88
|
+
"region": ["north", "north", None, "south", "south"],
|
|
89
|
+
"year": [2024, 2024, 2024, np.nan, 2025],
|
|
90
|
+
"income": [10.0, np.nan, 20.0, 30.0, 40.0],
|
|
91
|
+
},
|
|
92
|
+
weights=[1, 4, 2, 3, 1],
|
|
93
|
+
)
|
|
94
|
+
grouped = frame.groupby(["region", "year"], dropna=False, sort=sort)["income"]
|
|
95
|
+
result = grouped.quantile(quantiles, skipna=skipna)
|
|
96
|
+
|
|
97
|
+
# Each retained group has one nonmissing value. With skipna=False,
|
|
98
|
+
# the north group is NaN because it also contains a missing value.
|
|
99
|
+
north = 10.0 if skipna else np.nan
|
|
100
|
+
groups = [("north", 2024.0, north)]
|
|
101
|
+
if sort:
|
|
102
|
+
groups += [
|
|
103
|
+
("south", 2025.0, 40.0),
|
|
104
|
+
("south", np.nan, 30.0),
|
|
105
|
+
(np.nan, 2024.0, 20.0),
|
|
106
|
+
]
|
|
107
|
+
else:
|
|
108
|
+
groups += [
|
|
109
|
+
(np.nan, 2024.0, 20.0),
|
|
110
|
+
("south", np.nan, 30.0),
|
|
111
|
+
("south", 2025.0, 40.0),
|
|
112
|
+
]
|
|
113
|
+
expected_index = pd.MultiIndex.from_tuples(
|
|
114
|
+
[(region, year, q) for region, year, _ in groups for q in quantiles],
|
|
115
|
+
names=["region", "year", None],
|
|
116
|
+
)
|
|
117
|
+
expected_values = [value for _, _, value in groups for _ in quantiles]
|
|
118
|
+
assert result.index.names == expected_index.names
|
|
119
|
+
if quantiles:
|
|
120
|
+
for level in range(3):
|
|
121
|
+
pd.testing.assert_index_equal(
|
|
122
|
+
result.index.get_level_values(level),
|
|
123
|
+
expected_index.get_level_values(level),
|
|
124
|
+
)
|
|
125
|
+
assert result.index.nlevels == 3
|
|
126
|
+
np.testing.assert_allclose(result.to_numpy(), expected_values, equal_nan=True)
|
|
127
|
+
for q in set(quantiles):
|
|
128
|
+
if quantiles.count(q) == 1:
|
|
129
|
+
selected = result.xs(q, level=-1)
|
|
130
|
+
scalar = grouped.quantile(q, skipna=skipna)
|
|
131
|
+
assert selected.index.equals(scalar.index)
|
|
132
|
+
np.testing.assert_allclose(
|
|
133
|
+
selected.to_numpy(), scalar.to_numpy(), equal_nan=True
|
|
134
|
+
)
|
|
@@ -9,6 +9,7 @@ microdf/tests/test_dataframe_weight_storage.py
|
|
|
9
9
|
microdf/tests/test_microseries_dataframe.py
|
|
10
10
|
microdf/tests/test_nullify_weights_index.py
|
|
11
11
|
microdf/tests/test_pandas3_compatibility.py
|
|
12
|
+
microdf/tests/test_quantile_missing_values.py
|
|
12
13
|
microdf_python.egg-info/PKG-INFO
|
|
13
14
|
microdf_python.egg-info/SOURCES.txt
|
|
14
15
|
microdf_python.egg-info/dependency_links.txt
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{microdf_python-1.3.4 → microdf_python-1.3.5}/microdf/tests/test_dataframe_weight_storage.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|