microdf-python 1.0.2__tar.gz → 1.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microdf-python
3
- Version: 1.0.2
3
+ Version: 1.1.1
4
4
  Summary: Weighted pandas DataFrames and Series for survey microdata
5
5
  Author-email: Max Ghenis <max@policyengine.org>
6
6
  License: MIT
@@ -20,8 +20,6 @@ Requires-Dist: linecheck; extra == "dev"
20
20
  Requires-Dist: pytest; extra == "dev"
21
21
  Requires-Dist: pytest-cov; extra == "dev"
22
22
  Requires-Dist: setuptools; extra == "dev"
23
- Provides-Extra: docs
24
- Requires-Dist: jupyter_book; extra == "docs"
25
23
  Dynamic: license-file
26
24
 
27
25
  [![Build](https://github.com/PolicyEngine/microdf/workflows/Pull%20request/badge.svg)](https://github.com/PolicyEngine/microdf/actions)
@@ -12,6 +12,80 @@ from microdf.microseries import MicroSeries, MicroSeriesGroupBy
12
12
  logger = logging.getLogger(__name__)
13
13
 
14
14
 
15
+ class _MicroLocIndexer:
16
+ """Custom loc indexer that returns MicroDataFrame with proper weights."""
17
+
18
+ def __init__(self, mdf: "MicroDataFrame"):
19
+ self._mdf = mdf
20
+ # Get the parent's loc indexer
21
+ self._parent_loc = pd.DataFrame.loc.fget(mdf)
22
+
23
+ def __getitem__(self, key):
24
+ # Use the parent DataFrame's loc indexer
25
+ result = self._parent_loc[key]
26
+
27
+ if isinstance(result, pd.DataFrame):
28
+ # Get the filtered weights based on the result's index
29
+ new_weights = self._mdf.weights.reindex(result.index)
30
+ return MicroDataFrame(result, weights=new_weights)
31
+ elif isinstance(result, pd.Series):
32
+ # Single row or column selected
33
+ if result.name in self._mdf.columns:
34
+ # Column was selected - return MicroSeries with all weights
35
+ return MicroSeries(result, weights=self._mdf.weights)
36
+ else:
37
+ # Row was selected - return as-is (scalar values for each col)
38
+ return result
39
+ else:
40
+ # Scalar value
41
+ return result
42
+
43
+ def __setitem__(self, key, value):
44
+ self._parent_loc[key] = value
45
+ self._mdf._link_all_weights()
46
+
47
+
48
+ class _MicroILocIndexer:
49
+ """Custom iloc indexer that returns MicroDataFrame with proper weights."""
50
+
51
+ def __init__(self, mdf: "MicroDataFrame"):
52
+ self._mdf = mdf
53
+ # Get the parent's iloc indexer
54
+ self._parent_iloc = pd.DataFrame.iloc.fget(mdf)
55
+
56
+ def __getitem__(self, key):
57
+ # Use the parent DataFrame's iloc indexer
58
+ result = self._parent_iloc[key]
59
+
60
+ if isinstance(result, pd.DataFrame):
61
+ # Get the filtered weights based on the result's index
62
+ new_weights = self._mdf.weights.iloc[
63
+ self._mdf.index.get_indexer(result.index)
64
+ ]
65
+ new_weights = pd.Series(new_weights.values, index=result.index)
66
+ return MicroDataFrame(result, weights=new_weights)
67
+ elif isinstance(result, pd.Series):
68
+ # Single row or column selected
69
+ if isinstance(key, tuple) and len(key) == 2:
70
+ # df.iloc[:, col_idx] - column selection
71
+ row_key = key[0]
72
+ if isinstance(row_key, slice) and row_key == slice(None):
73
+ # All rows selected for a column
74
+ return MicroSeries(result, weights=self._mdf.weights)
75
+ # Check if this is a column (result index matches mdf index)
76
+ if result.index.equals(self._mdf.index):
77
+ return MicroSeries(result, weights=self._mdf.weights)
78
+ # Row selection - return as-is
79
+ return result
80
+ else:
81
+ # Scalar value
82
+ return result
83
+
84
+ def __setitem__(self, key, value):
85
+ self._parent_iloc[key] = value
86
+ self._mdf._link_all_weights()
87
+
88
+
15
89
  class MicroDataFrame(pd.DataFrame):
16
90
  def __init__(self, *args, weights=None, **kwargs):
17
91
  """A DataFrame-inheriting class for weighted microdata. Weights can be
@@ -26,6 +100,23 @@ class MicroDataFrame(pd.DataFrame):
26
100
  self._link_all_weights()
27
101
  self.override_df_functions()
28
102
 
103
+ @property
104
+ def loc(self) -> _MicroLocIndexer:
105
+ """Label-based indexer that preserves MicroDataFrame type and weights.
106
+
107
+ :return: Custom loc indexer for MicroDataFrame
108
+ """
109
+ return _MicroLocIndexer(self)
110
+
111
+ @property
112
+ def iloc(self) -> _MicroILocIndexer:
113
+ """Integer-based indexer that preserves MicroDataFrame type and
114
+ weights.
115
+
116
+ :return: Custom iloc indexer for MicroDataFrame
117
+ """
118
+ return _MicroILocIndexer(self)
119
+
29
120
  def override_df_functions(self) -> None:
30
121
  """Override DataFrame functions to work with weighted operations."""
31
122
  for name in MicroSeries.FUNCTIONS:
@@ -643,6 +734,7 @@ class MicroDataFrame(pd.DataFrame):
643
734
 
644
735
  class MicroDataFrameGroupBy(pd.core.groupby.generic.DataFrameGroupBy):
645
736
  def _init(self, by: Union[str, List]):
737
+ self._by = by
646
738
  self.columns = list(self.obj.columns)
647
739
  if isinstance(by, list):
648
740
  for column in by:
@@ -656,6 +748,10 @@ class MicroDataFrameGroupBy(pd.core.groupby.generic.DataFrameGroupBy):
656
748
  for col in self.columns
657
749
  if pd.api.types.is_numeric_dtype(self.obj[col])
658
750
  ]
751
+ # Store reference to weights groupby for column selection
752
+ self._weights_groupby = copy.deepcopy(
753
+ super().__getitem__("__tmp_weights")
754
+ )
659
755
  for fn_name in MicroSeries.SCALAR_FUNCTIONS:
660
756
 
661
757
  def get_fn(name):
@@ -669,11 +765,9 @@ class MicroDataFrameGroupBy(pd.core.groupby.generic.DataFrameGroupBy):
669
765
  except Exception:
670
766
  # Skip columns that can't be aggregated
671
767
  pass
672
- return (
673
- MicroDataFrame(results)
674
- if results
675
- else MicroDataFrame()
676
- )
768
+ # Return plain DataFrame - aggregated results don't have
769
+ # per-row weights (weights were already applied)
770
+ return pd.DataFrame(results) if results else pd.DataFrame()
677
771
 
678
772
  return fn
679
773
 
@@ -691,12 +785,99 @@ class MicroDataFrameGroupBy(pd.core.groupby.generic.DataFrameGroupBy):
691
785
  except Exception:
692
786
  # Skip columns that can't be aggregated
693
787
  pass
694
- return (
695
- MicroDataFrame(results)
696
- if results
697
- else MicroDataFrame()
698
- )
788
+ # Return plain DataFrame - aggregated results don't have
789
+ # per-row weights (weights were already applied)
790
+ return pd.DataFrame(results) if results else pd.DataFrame()
699
791
 
700
792
  return fn
701
793
 
702
794
  setattr(self, fn_name, get_fn(fn_name))
795
+
796
+ def __getitem__(
797
+ self, key: Union[str, List]
798
+ ) -> Union["MicroSeriesGroupBy", "MicroDataFrameGroupBy"]:
799
+ """Select columns from the groupby object while preserving weights.
800
+
801
+ This ensures that operations like groupby(col)["y"].sum() or
802
+ groupby(col)[["y"]].sum() use weighted aggregation.
803
+
804
+ :param key: Column name or list of column names
805
+ :return: MicroSeriesGroupBy for single column, MicroDataFrameGroupBy
806
+ for multiple columns
807
+ """
808
+ if isinstance(key, str):
809
+ # Single column - return MicroSeriesGroupBy
810
+ result = super().__getitem__(key)
811
+ result.__class__ = MicroSeriesGroupBy
812
+ result._init()
813
+ result.weights = self._weights_groupby
814
+ return result
815
+ else:
816
+ # Multiple columns - return a new MicroDataFrameGroupBy
817
+ # with only the selected columns
818
+ result = super().__getitem__(key)
819
+ result.__class__ = MicroDataFrameGroupBy
820
+ # Re-initialize with the subset of columns
821
+ result._by = self._by
822
+ result.columns = list(key) if hasattr(key, "__iter__") else [key]
823
+ result.numeric_columns = [
824
+ col
825
+ for col in result.columns
826
+ if pd.api.types.is_numeric_dtype(result.obj[col])
827
+ ]
828
+ result._weights_groupby = self._weights_groupby
829
+ # Set up the column attributes as MicroSeriesGroupBy
830
+ for col in result.columns:
831
+ col_gb = super().__getitem__(col)
832
+ col_gb.__class__ = MicroSeriesGroupBy
833
+ col_gb._init()
834
+ col_gb.weights = self._weights_groupby
835
+ setattr(result, col, col_gb)
836
+ # Set up the scalar and vector functions
837
+ for fn_name in MicroSeries.SCALAR_FUNCTIONS:
838
+
839
+ def get_scalar_fn(name, res):
840
+ def fn(*args, **kwargs):
841
+ results = {}
842
+ for col in res.numeric_columns:
843
+ try:
844
+ results[col] = getattr(
845
+ getattr(res, col), name
846
+ )(*args, **kwargs)
847
+ except Exception:
848
+ pass
849
+ # Return plain DataFrame - aggregated results don't
850
+ # have per-row weights (weights were already applied)
851
+ return (
852
+ pd.DataFrame(results)
853
+ if results
854
+ else pd.DataFrame()
855
+ )
856
+
857
+ return fn
858
+
859
+ setattr(result, fn_name, get_scalar_fn(fn_name, result))
860
+ for fn_name in MicroSeries.VECTOR_FUNCTIONS:
861
+
862
+ def get_vector_fn(name, res):
863
+ def fn(*args, **kwargs):
864
+ results = {}
865
+ for col in res.numeric_columns:
866
+ try:
867
+ results[col] = getattr(
868
+ getattr(res, col), name
869
+ )(*args, **kwargs)
870
+ except Exception:
871
+ pass
872
+ # Return plain DataFrame - aggregated results don't
873
+ # have per-row weights (weights were already applied)
874
+ return (
875
+ pd.DataFrame(results)
876
+ if results
877
+ else pd.DataFrame()
878
+ )
879
+
880
+ return fn
881
+
882
+ setattr(result, fn_name, get_vector_fn(fn_name, result))
883
+ return result
@@ -112,17 +112,18 @@ class MicroSeries(pd.Series):
112
112
  def quantile(self, q: np.array) -> pd.Series:
113
113
  """Calculates weighted quantiles of the MicroSeries.
114
114
 
115
- Doesn't exactly match unweighted quantiles of stacked values.
116
- See stackoverflow.com/q/21844024#comment102342137_29677616.
115
+ Uses the inverse CDF method: the q-th quantile is the smallest
116
+ value where the cumulative weight proportion >= q. This matches
117
+ the default behavior of R's survey::svyquantile.
117
118
 
118
- :param q: Array of quantiles to calculate.
119
- :type q: np.array
119
+ :param q: Quantile(s) to calculate, must be in [0, 1].
120
+ :type q: float or np.array
120
121
 
121
- :return: Array of weighted quantiles.
122
- :rtype: pd.Series
122
+ :return: Weighted quantile value(s).
123
+ :rtype: float or pd.Series
123
124
  """
124
125
  values = np.array(self.values)
125
- quantiles = np.array(q)
126
+ quantiles = np.atleast_1d(q)
126
127
  sample_weight = np.array(self.weights)
127
128
  assert np.all(quantiles >= 0) and np.all(
128
129
  quantiles <= 1
@@ -130,11 +131,20 @@ class MicroSeries(pd.Series):
130
131
  sorter = np.argsort(values)
131
132
  values = values[sorter]
132
133
  sample_weight = sample_weight[sorter]
133
- weighted_quantiles = np.cumsum(sample_weight) - 0.5 * sample_weight
134
- weighted_quantiles /= np.sum(sample_weight)
135
- result = np.interp(quantiles, weighted_quantiles, values)
136
- if quantiles.shape == ():
137
- return result
134
+ cumsum = np.cumsum(sample_weight)
135
+ cumsum_normalized = cumsum / cumsum[-1]
136
+ result = np.array(
137
+ [
138
+ values[
139
+ min(
140
+ np.searchsorted(cumsum_normalized, qi), len(values) - 1
141
+ )
142
+ ]
143
+ for qi in quantiles
144
+ ]
145
+ )
146
+ if np.array(q).shape == ():
147
+ return result[0]
138
148
  return pd.Series(result, index=quantiles)
139
149
 
140
150
  @scalar_function
@@ -112,6 +112,55 @@ def test_median() -> None:
112
112
  assert series.median() == 4
113
113
 
114
114
 
115
+ def test_weighted_quantile_skewed() -> None:
116
+ # 99% of the population has 0 income, 1% has 1M
117
+ # The median should be 0, not an interpolated value
118
+ series = mdf.MicroSeries([0, 1_000_000], weights=[99, 1])
119
+ assert series.median() == 0
120
+ assert series.quantile(0.5) == 0
121
+ # 99th percentile is still 0 since exactly 99% have 0
122
+ assert series.quantile(0.99) == 0
123
+ # Only quantile > 0.99 gives 1M
124
+ assert series.quantile(1.0) == 1_000_000
125
+ # Test multiple quantiles
126
+ result = series.quantile([0.1, 0.5, 0.99, 1.0])
127
+ assert result[0.1] == 0
128
+ assert result[0.5] == 0
129
+ assert result[0.99] == 0
130
+ assert result[1.0] == 1_000_000
131
+
132
+
133
+ def test_weighted_quantile_boundaries() -> None:
134
+ # Test q=0 returns minimum, q=1 returns maximum
135
+ series = mdf.MicroSeries([10, 20, 30], weights=[1, 1, 1])
136
+ assert series.quantile(0.0) == 10
137
+ assert series.quantile(1.0) == 30
138
+
139
+
140
+ def test_weighted_quantile_equal_weights() -> None:
141
+ # With equal weights, should match "replicated" interpretation
142
+ # Values: 1, 2, 3 each with weight 2 -> like [1,1,2,2,3,3]
143
+ series = mdf.MicroSeries([1, 2, 3], weights=[2, 2, 2])
144
+ # cumsum_normalized = [2/6, 4/6, 6/6] = [0.333, 0.667, 1.0]
145
+ # median (0.5): smallest where cumsum >= 0.5 -> index 1 -> value 2
146
+ assert series.median() == 2
147
+ # 0.25 quantile: smallest where cumsum >= 0.25 -> index 0 -> value 1
148
+ assert series.quantile(0.25) == 1
149
+ # 0.75 quantile: smallest where cumsum >= 0.75 -> index 2 -> value 3
150
+ assert series.quantile(0.75) == 3
151
+
152
+
153
+ def test_weighted_quantile_unsorted_input() -> None:
154
+ # Ensure sorting works correctly
155
+ series = mdf.MicroSeries([30, 10, 20], weights=[1, 2, 1])
156
+ # Sorted: values [10, 20, 30], weights [2, 1, 1]
157
+ # cumsum_normalized = [0.5, 0.75, 1.0]
158
+ assert series.quantile(0.0) == 10
159
+ assert series.quantile(0.5) == 10 # cumsum[0]=0.5 >= 0.5
160
+ assert series.quantile(0.6) == 20 # cumsum[1]=0.75 >= 0.6
161
+ assert series.quantile(1.0) == 30
162
+
163
+
115
164
  def test_unweighted_groupby() -> None:
116
165
  df = mdf.MicroDataFrame({"x": [1, 2], "y": [3, 4], "z": [5, 6]})
117
166
  assert (df.groupby("x").z.sum().values == np.array([5.0, 6.0])).all()
@@ -287,3 +336,60 @@ def test_reset_index_inplace() -> None:
287
336
  assert "second" in mdf_multi.columns
288
337
  assert list(mdf_multi.index) == [0, 1, 2, 3]
289
338
  np.testing.assert_array_equal(mdf_multi.weights.values, weights)
339
+
340
+
341
+ def test_loc_preserves_weights() -> None:
342
+ """Test that .loc[] returns MicroDataFrame with proper weights (issue
343
+ #265)."""
344
+ df = mdf.MicroDataFrame(
345
+ {"one": [1, 1, 1, 1, 1]}, weights=[10, 20, 30, 40, 50]
346
+ )
347
+
348
+ # Filter all rows (should get same weights)
349
+ filtered = df.loc[df.one == 1]
350
+ assert isinstance(filtered, MicroDataFrame)
351
+ assert filtered.one.sum() == 150.0 # Weighted sum
352
+
353
+ # Partial filter
354
+ df2 = mdf.MicroDataFrame(
355
+ {"x": [1, 2, 3, 4, 5]}, weights=[10, 20, 30, 40, 50]
356
+ )
357
+ subset = df2.loc[df2.x > 2]
358
+ assert isinstance(subset, MicroDataFrame)
359
+ assert subset.x.sum() == 500.0 # 3*30 + 4*40 + 5*50 = 500
360
+ np.testing.assert_array_equal(subset.weights.values, [30.0, 40.0, 50.0])
361
+
362
+
363
+ def test_iloc_preserves_weights() -> None:
364
+ """Test that .iloc[] returns MicroDataFrame with proper weights."""
365
+ df = mdf.MicroDataFrame(
366
+ {"x": [1, 2, 3, 4, 5]}, weights=[10, 20, 30, 40, 50]
367
+ )
368
+
369
+ # Select rows by position
370
+ subset = df.iloc[2:5]
371
+ assert isinstance(subset, MicroDataFrame)
372
+ assert subset.x.sum() == 500.0 # 3*30 + 4*40 + 5*50 = 500
373
+ np.testing.assert_array_equal(subset.weights.values, [30.0, 40.0, 50.0])
374
+
375
+
376
+ def test_groupby_column_selection() -> None:
377
+ """Test that groupby column selection preserves weights (issue #193)."""
378
+ d = mdf.MicroDataFrame(
379
+ dict(g=["a", "a", "b"], y=[1, 2, 3]), weights=[4, 5, 6]
380
+ )
381
+
382
+ # Test single column string selection
383
+ result_str = d.groupby("g")["y"].sum()
384
+ assert result_str["a"] == 14.0 # 1*4 + 2*5 = 14
385
+ assert result_str["b"] == 18.0 # 3*6 = 18
386
+
387
+ # Test list column selection
388
+ result_list = d.groupby("g")[["y"]].sum()
389
+ assert result_list.loc["a", "y"] == 14.0
390
+ assert result_list.loc["b", "y"] == 18.0
391
+
392
+ # Aggregated results should be plain DataFrame (no spurious weight column)
393
+ result_all = d.groupby("g").sum()
394
+ assert "weight" not in result_all.columns
395
+ assert list(result_all.columns) == ["y"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microdf-python
3
- Version: 1.0.2
3
+ Version: 1.1.1
4
4
  Summary: Weighted pandas DataFrames and Series for survey microdata
5
5
  Author-email: Max Ghenis <max@policyengine.org>
6
6
  License: MIT
@@ -20,8 +20,6 @@ Requires-Dist: linecheck; extra == "dev"
20
20
  Requires-Dist: pytest; extra == "dev"
21
21
  Requires-Dist: pytest-cov; extra == "dev"
22
22
  Requires-Dist: setuptools; extra == "dev"
23
- Provides-Extra: docs
24
- Requires-Dist: jupyter_book; extra == "docs"
25
23
  Dynamic: license-file
26
24
 
27
25
  [![Build](https://github.com/PolicyEngine/microdf/workflows/Pull%20request/badge.svg)](https://github.com/PolicyEngine/microdf/actions)
@@ -12,6 +12,3 @@ linecheck
12
12
  pytest
13
13
  pytest-cov
14
14
  setuptools
15
-
16
- [docs]
17
- jupyter_book
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "microdf-python"
7
- version = "1.0.2"
7
+ version = "1.1.1"
8
8
  description = "Weighted pandas DataFrames and Series for survey microdata"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -30,9 +30,8 @@ dev = [
30
30
  "pytest-cov",
31
31
  "setuptools",
32
32
  ]
33
- docs = [
34
- "jupyter_book",
35
- ]
33
+ # Note: Documentation uses MyST (Jupyter Book 2.0) which is installed via npm
34
+ # Run: cd docs && myst build --html
36
35
 
37
36
  [tool.setuptools.packages.find]
38
37
  where = ["."]
File without changes
File without changes
File without changes