microdf-python 1.1.0__tar.gz → 1.1.2__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.1.0
3
+ Version: 1.1.2
4
4
  Summary: Weighted pandas DataFrames and Series for survey microdata
5
5
  Author-email: Max Ghenis <max@policyengine.org>
6
6
  License: MIT
@@ -44,6 +44,10 @@ class _MicroLocIndexer:
44
44
  self._parent_loc[key] = value
45
45
  self._mdf._link_all_weights()
46
46
 
47
+ def __getattr__(self, name):
48
+ """Delegate unknown attributes to the parent loc indexer."""
49
+ return getattr(self._parent_loc, name)
50
+
47
51
 
48
52
  class _MicroILocIndexer:
49
53
  """Custom iloc indexer that returns MicroDataFrame with proper weights."""
@@ -85,6 +89,10 @@ class _MicroILocIndexer:
85
89
  self._parent_iloc[key] = value
86
90
  self._mdf._link_all_weights()
87
91
 
92
+ def __getattr__(self, name):
93
+ """Delegate unknown attributes to the parent iloc indexer."""
94
+ return getattr(self._parent_iloc, name)
95
+
88
96
 
89
97
  class MicroDataFrame(pd.DataFrame):
90
98
  def __init__(self, *args, weights=None, **kwargs):
@@ -101,28 +101,48 @@ class MicroSeries(pd.Series):
101
101
  return self.weights.sum()
102
102
 
103
103
  @scalar_function
104
- def mean(self) -> float:
104
+ def mean(self, skipna: bool = True) -> float:
105
105
  """Calculates the weighted mean of the MicroSeries.
106
106
 
107
+ :param skipna: Exclude NA/null values. If True (default), NaN values
108
+ are excluded. If False, returns NaN if any value is NaN.
109
+ :type skipna: bool
107
110
  :returns: The weighted mean.
108
111
  :rtype: float
109
112
  """
110
- return np.average(self.values, weights=self.weights)
113
+ values = self.values
114
+ weights = self.weights
115
+
116
+ if skipna:
117
+ # Create mask for non-NaN values
118
+ mask = ~pd.isna(values)
119
+ if not mask.any():
120
+ # All values are NaN
121
+ return np.nan
122
+ values = values[mask]
123
+ weights = weights[mask]
124
+
125
+ # If skipna=False and there are any NaN values, return NaN
126
+ if not skipna and pd.isna(values).any():
127
+ return np.nan
128
+
129
+ return np.average(values, weights=weights)
111
130
 
112
131
  def quantile(self, q: np.array) -> pd.Series:
113
132
  """Calculates weighted quantiles of the MicroSeries.
114
133
 
115
- Doesn't exactly match unweighted quantiles of stacked values.
116
- See stackoverflow.com/q/21844024#comment102342137_29677616.
134
+ Uses the inverse CDF method: the q-th quantile is the smallest
135
+ value where the cumulative weight proportion >= q. This matches
136
+ the default behavior of R's survey::svyquantile.
117
137
 
118
- :param q: Array of quantiles to calculate.
119
- :type q: np.array
138
+ :param q: Quantile(s) to calculate, must be in [0, 1].
139
+ :type q: float or np.array
120
140
 
121
- :return: Array of weighted quantiles.
122
- :rtype: pd.Series
141
+ :return: Weighted quantile value(s).
142
+ :rtype: float or pd.Series
123
143
  """
124
144
  values = np.array(self.values)
125
- quantiles = np.array(q)
145
+ quantiles = np.atleast_1d(q)
126
146
  sample_weight = np.array(self.weights)
127
147
  assert np.all(quantiles >= 0) and np.all(
128
148
  quantiles <= 1
@@ -130,11 +150,20 @@ class MicroSeries(pd.Series):
130
150
  sorter = np.argsort(values)
131
151
  values = values[sorter]
132
152
  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
153
+ cumsum = np.cumsum(sample_weight)
154
+ cumsum_normalized = cumsum / cumsum[-1]
155
+ result = np.array(
156
+ [
157
+ values[
158
+ min(
159
+ np.searchsorted(cumsum_normalized, qi), len(values) - 1
160
+ )
161
+ ]
162
+ for qi in quantiles
163
+ ]
164
+ )
165
+ if np.array(q).shape == ():
166
+ return result[0]
138
167
  return pd.Series(result, index=quantiles)
139
168
 
140
169
  @scalar_function
@@ -94,6 +94,36 @@ def test_mean() -> None:
94
94
  pass
95
95
 
96
96
 
97
+ def test_mean_skipna() -> None:
98
+ # Test skipna=True (default) - should skip NaN values
99
+ arr = np.array([3.0, np.nan, 2.0])
100
+ w = np.array([4.0, 1.0, 1.0])
101
+ series = mdf.MicroSeries(arr, weights=w)
102
+
103
+ # skipna=True should exclude NaN and its weight
104
+ expected = np.average([3.0, 2.0], weights=[4.0, 1.0])
105
+ assert series.mean(skipna=True) == expected
106
+ assert series.mean() == expected # Default should be skipna=True
107
+
108
+ # Test skipna=False - should return NaN if any value is NaN
109
+ assert np.isnan(series.mean(skipna=False))
110
+
111
+ # Test with all NaN values
112
+ arr_all_nan = np.array([np.nan, np.nan, np.nan])
113
+ w_all_nan = np.array([1.0, 2.0, 3.0])
114
+ series_all_nan = mdf.MicroSeries(arr_all_nan, weights=w_all_nan)
115
+ assert np.isnan(series_all_nan.mean(skipna=True))
116
+ assert np.isnan(series_all_nan.mean(skipna=False))
117
+
118
+ # Test with no NaN values - skipna should not affect result
119
+ arr_no_nan = np.array([3.0, 5.0, 2.0])
120
+ w_no_nan = np.array([4.0, 1.0, 1.0])
121
+ series_no_nan = mdf.MicroSeries(arr_no_nan, weights=w_no_nan)
122
+ expected_no_nan = np.average(arr_no_nan, weights=w_no_nan)
123
+ assert series_no_nan.mean(skipna=True) == expected_no_nan
124
+ assert series_no_nan.mean(skipna=False) == expected_no_nan
125
+
126
+
97
127
  def test_poverty_count() -> None:
98
128
  arr = np.array([10000, 20000, 50000])
99
129
  w = np.array([1123, 1144, 2211])
@@ -112,6 +142,55 @@ def test_median() -> None:
112
142
  assert series.median() == 4
113
143
 
114
144
 
145
+ def test_weighted_quantile_skewed() -> None:
146
+ # 99% of the population has 0 income, 1% has 1M
147
+ # The median should be 0, not an interpolated value
148
+ series = mdf.MicroSeries([0, 1_000_000], weights=[99, 1])
149
+ assert series.median() == 0
150
+ assert series.quantile(0.5) == 0
151
+ # 99th percentile is still 0 since exactly 99% have 0
152
+ assert series.quantile(0.99) == 0
153
+ # Only quantile > 0.99 gives 1M
154
+ assert series.quantile(1.0) == 1_000_000
155
+ # Test multiple quantiles
156
+ result = series.quantile([0.1, 0.5, 0.99, 1.0])
157
+ assert result[0.1] == 0
158
+ assert result[0.5] == 0
159
+ assert result[0.99] == 0
160
+ assert result[1.0] == 1_000_000
161
+
162
+
163
+ def test_weighted_quantile_boundaries() -> None:
164
+ # Test q=0 returns minimum, q=1 returns maximum
165
+ series = mdf.MicroSeries([10, 20, 30], weights=[1, 1, 1])
166
+ assert series.quantile(0.0) == 10
167
+ assert series.quantile(1.0) == 30
168
+
169
+
170
+ def test_weighted_quantile_equal_weights() -> None:
171
+ # With equal weights, should match "replicated" interpretation
172
+ # Values: 1, 2, 3 each with weight 2 -> like [1,1,2,2,3,3]
173
+ series = mdf.MicroSeries([1, 2, 3], weights=[2, 2, 2])
174
+ # cumsum_normalized = [2/6, 4/6, 6/6] = [0.333, 0.667, 1.0]
175
+ # median (0.5): smallest where cumsum >= 0.5 -> index 1 -> value 2
176
+ assert series.median() == 2
177
+ # 0.25 quantile: smallest where cumsum >= 0.25 -> index 0 -> value 1
178
+ assert series.quantile(0.25) == 1
179
+ # 0.75 quantile: smallest where cumsum >= 0.75 -> index 2 -> value 3
180
+ assert series.quantile(0.75) == 3
181
+
182
+
183
+ def test_weighted_quantile_unsorted_input() -> None:
184
+ # Ensure sorting works correctly
185
+ series = mdf.MicroSeries([30, 10, 20], weights=[1, 2, 1])
186
+ # Sorted: values [10, 20, 30], weights [2, 1, 1]
187
+ # cumsum_normalized = [0.5, 0.75, 1.0]
188
+ assert series.quantile(0.0) == 10
189
+ assert series.quantile(0.5) == 10 # cumsum[0]=0.5 >= 0.5
190
+ assert series.quantile(0.6) == 20 # cumsum[1]=0.75 >= 0.6
191
+ assert series.quantile(1.0) == 30
192
+
193
+
115
194
  def test_unweighted_groupby() -> None:
116
195
  df = mdf.MicroDataFrame({"x": [1, 2], "y": [3, 4], "z": [5, 6]})
117
196
  assert (df.groupby("x").z.sum().values == np.array([5.0, 6.0])).all()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microdf-python
3
- Version: 1.1.0
3
+ Version: 1.1.2
4
4
  Summary: Weighted pandas DataFrames and Series for survey microdata
5
5
  Author-email: Max Ghenis <max@policyengine.org>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "microdf-python"
7
- version = "1.1.0"
7
+ version = "1.1.2"
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