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.
- {microdf_python-1.0.2/microdf_python.egg-info → microdf_python-1.1.1}/PKG-INFO +1 -3
- {microdf_python-1.0.2 → microdf_python-1.1.1}/microdf/microdataframe.py +191 -10
- {microdf_python-1.0.2 → microdf_python-1.1.1}/microdf/microseries.py +22 -12
- {microdf_python-1.0.2 → microdf_python-1.1.1}/microdf/tests/test_microseries_dataframe.py +106 -0
- {microdf_python-1.0.2 → microdf_python-1.1.1/microdf_python.egg-info}/PKG-INFO +1 -3
- {microdf_python-1.0.2 → microdf_python-1.1.1}/microdf_python.egg-info/requires.txt +0 -3
- {microdf_python-1.0.2 → microdf_python-1.1.1}/pyproject.toml +3 -4
- {microdf_python-1.0.2 → microdf_python-1.1.1}/LICENSE +0 -0
- {microdf_python-1.0.2 → microdf_python-1.1.1}/README.md +0 -0
- {microdf_python-1.0.2 → microdf_python-1.1.1}/microdf/__init__.py +0 -0
- {microdf_python-1.0.2 → microdf_python-1.1.1}/microdf/tests/conftest.py +0 -0
- {microdf_python-1.0.2 → microdf_python-1.1.1}/microdf_python.egg-info/SOURCES.txt +0 -0
- {microdf_python-1.0.2 → microdf_python-1.1.1}/microdf_python.egg-info/dependency_links.txt +0 -0
- {microdf_python-1.0.2 → microdf_python-1.1.1}/microdf_python.egg-info/top_level.txt +0 -0
- {microdf_python-1.0.2 → microdf_python-1.1.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: microdf-python
|
|
3
|
-
Version: 1.
|
|
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
|
[](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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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:
|
|
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:
|
|
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.
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
result = np.
|
|
136
|
-
|
|
137
|
-
|
|
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.
|
|
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
|
[](https://github.com/PolicyEngine/microdf/actions)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "microdf-python"
|
|
7
|
-
version = "1.
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|