microdf-python 1.3.3__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.
Files changed (19) hide show
  1. {microdf_python-1.3.3/microdf_python.egg-info → microdf_python-1.3.5}/PKG-INFO +1 -1
  2. {microdf_python-1.3.3 → microdf_python-1.3.5}/microdf/microdataframe.py +9 -6
  3. {microdf_python-1.3.3 → microdf_python-1.3.5}/microdf/microseries.py +52 -4
  4. microdf_python-1.3.5/microdf/tests/test_dataframe_weight_storage.py +61 -0
  5. microdf_python-1.3.5/microdf/tests/test_quantile_missing_values.py +134 -0
  6. {microdf_python-1.3.3 → microdf_python-1.3.5/microdf_python.egg-info}/PKG-INFO +1 -1
  7. {microdf_python-1.3.3 → microdf_python-1.3.5}/microdf_python.egg-info/SOURCES.txt +2 -0
  8. {microdf_python-1.3.3 → microdf_python-1.3.5}/pyproject.toml +1 -1
  9. {microdf_python-1.3.3 → microdf_python-1.3.5}/LICENSE +0 -0
  10. {microdf_python-1.3.3 → microdf_python-1.3.5}/README.md +0 -0
  11. {microdf_python-1.3.3 → microdf_python-1.3.5}/microdf/__init__.py +0 -0
  12. {microdf_python-1.3.3 → microdf_python-1.3.5}/microdf/tests/conftest.py +0 -0
  13. {microdf_python-1.3.3 → microdf_python-1.3.5}/microdf/tests/test_microseries_dataframe.py +0 -0
  14. {microdf_python-1.3.3 → microdf_python-1.3.5}/microdf/tests/test_nullify_weights_index.py +0 -0
  15. {microdf_python-1.3.3 → microdf_python-1.3.5}/microdf/tests/test_pandas3_compatibility.py +0 -0
  16. {microdf_python-1.3.3 → microdf_python-1.3.5}/microdf_python.egg-info/dependency_links.txt +0 -0
  17. {microdf_python-1.3.3 → microdf_python-1.3.5}/microdf_python.egg-info/requires.txt +0 -0
  18. {microdf_python-1.3.3 → microdf_python-1.3.5}/microdf_python.egg-info/top_level.txt +0 -0
  19. {microdf_python-1.3.3 → microdf_python-1.3.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microdf-python
3
- Version: 1.3.3
3
+ Version: 1.3.5
4
4
  Summary: Weighted pandas DataFrames and Series for survey microdata
5
5
  Author-email: Max Ghenis <max@policyengine.org>
6
6
  License: MIT
@@ -310,8 +310,9 @@ class MicroDataFrame(pd.DataFrame):
310
310
 
311
311
  if isinstance(weights, str):
312
312
  self.weights_col = weights
313
+ # Keep stored weights independent from edits to the source column.
313
314
  self.weights = pd.Series(
314
- np.asarray(self[weights]),
315
+ np.array(self[weights], copy=True),
315
316
  index=self.index,
316
317
  dtype=float,
317
318
  )
@@ -362,9 +363,9 @@ class MicroDataFrame(pd.DataFrame):
362
363
  if preserve_old and self.weights_col is not None:
363
364
  self["old_" + self.weights_col] = self.weights
364
365
 
365
- self.weights = np.array(self[column])
366
- self.weights_col = column
367
- self._link_all_weights()
366
+ # Delegate to set_weights: it validates length and builds an
367
+ # index-aligned float Series rather than a bare ndarray.
368
+ self.set_weights(column)
368
369
 
369
370
  def nullify_weights(self) -> None:
370
371
  """Set all weights to 1, effectively making the DataFrame unweighted.
@@ -372,8 +373,10 @@ class MicroDataFrame(pd.DataFrame):
372
373
  This is useful for comparing weighted and unweighted statistics or when
373
374
  you want to temporarily ignore weights.
374
375
  """
375
- self.weights = np.ones(len(self))
376
- self._link_all_weights()
376
+ # Route through set_weights so self.weights stays an index-aligned
377
+ # float Series. Assigning a bare ndarray here broke every caller
378
+ # that treats it as a Series (equals(), reindex() in __getitem__).
379
+ self.set_weights(np.ones(len(self)))
377
380
 
378
381
  def __getitem__(
379
382
  self, key: Union[str, List]
@@ -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
- nonzero = sample_weight > 0
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,61 @@
1
+ import warnings
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ import pytest
6
+
7
+ import microdf as mdf
8
+
9
+
10
+ def test_weights_stay_a_series_after_nullify():
11
+ """nullify_weights must leave weights as an index-aligned Series."""
12
+ df = mdf.MicroDataFrame(pd.DataFrame({"x": [1, 2, 3]}), weights=[4, 5, 6])
13
+ df.nullify_weights()
14
+ assert isinstance(df.weights, pd.Series)
15
+ assert list(df.weights.index) == list(df.index)
16
+ assert df.equals(df)
17
+ assert df.sum()["x"] == 6
18
+
19
+
20
+ def test_weights_stay_a_series_after_set_weight_col():
21
+ """The deprecated set_weight_col must also produce a Series."""
22
+ df = mdf.MicroDataFrame(pd.DataFrame({"x": [1, 2, 3], "w": [1.0, 2.0, 3.0]}))
23
+ with warnings.catch_warnings():
24
+ warnings.simplefilter("ignore", DeprecationWarning)
25
+ df.set_weight_col("w")
26
+ assert isinstance(df.weights, pd.Series)
27
+ assert df.weights_col == "w"
28
+ assert df.equals(df)
29
+ assert df.sum()["x"] == 14
30
+
31
+
32
+ @pytest.mark.parametrize("dtype", ["int64", "float64"])
33
+ def test_weight_column_edits_do_not_change_stored_weights(dtype):
34
+ """Selecting a weight column takes a copy of its current values."""
35
+ df = mdf.MicroDataFrame(
36
+ {"x": [10, 20], "w": np.array([1, 2], dtype=dtype)},
37
+ index=[10, 20],
38
+ )
39
+ with pytest.warns(DeprecationWarning):
40
+ df.set_weight_col("w")
41
+
42
+ df.loc[10, "w"] = 100
43
+
44
+ np.testing.assert_array_equal(df.weights, [1, 2])
45
+ assert df.sum()["x"] == 10 * 1 + 20 * 2
46
+
47
+
48
+ @pytest.mark.parametrize("dtype", ["int64", "float64"])
49
+ def test_stored_weight_edits_do_not_change_weight_column(dtype):
50
+ """Changing stored weights leaves the source column values intact."""
51
+ df = mdf.MicroDataFrame(
52
+ {"x": [10, 20], "w": np.array([1, 2], dtype=dtype)},
53
+ index=[10, 20],
54
+ )
55
+ with pytest.warns(DeprecationWarning):
56
+ df.set_weight_col("w")
57
+
58
+ df.weights.iloc[0] = 100
59
+
60
+ np.testing.assert_array_equal(df["w"], [1, 2])
61
+ assert df.sum()["x"] == 10 * 100 + 20 * 2
@@ -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
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microdf-python
3
- Version: 1.3.3
3
+ Version: 1.3.5
4
4
  Summary: Weighted pandas DataFrames and Series for survey microdata
5
5
  Author-email: Max Ghenis <max@policyengine.org>
6
6
  License: MIT
@@ -5,9 +5,11 @@ microdf/__init__.py
5
5
  microdf/microdataframe.py
6
6
  microdf/microseries.py
7
7
  microdf/tests/conftest.py
8
+ microdf/tests/test_dataframe_weight_storage.py
8
9
  microdf/tests/test_microseries_dataframe.py
9
10
  microdf/tests/test_nullify_weights_index.py
10
11
  microdf/tests/test_pandas3_compatibility.py
12
+ microdf/tests/test_quantile_missing_values.py
11
13
  microdf_python.egg-info/PKG-INFO
12
14
  microdf_python.egg-info/SOURCES.txt
13
15
  microdf_python.egg-info/dependency_links.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "microdf-python"
7
- version = "1.3.3"
7
+ version = "1.3.5"
8
8
  description = "Weighted pandas DataFrames and Series for survey microdata"
9
9
  readme = "README.md"
10
10
  authors = [
File without changes
File without changes
File without changes