microdf-python 1.3.8__py3-none-any.whl → 1.3.10__py3-none-any.whl

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/microdataframe.py CHANGED
@@ -161,13 +161,60 @@ class MicroDataFrame(pd.DataFrame):
161
161
  def override_df_functions(self) -> None:
162
162
  """Override DataFrame functions to work with weighted operations."""
163
163
  for name in MicroSeries.FUNCTIONS:
164
- if name in MicroSeries.SCALAR_FUNCTIONS:
164
+ if name == "sum":
165
+ # Sum has its own axis-aware signature and result types.
166
+ continue
167
+ elif name in MicroSeries.SCALAR_FUNCTIONS:
165
168
  setattr(self, name, self._create_scalar_function(name))
166
169
  elif name in MicroSeries.VECTOR_FUNCTIONS:
167
170
  setattr(self, name, self._create_vector_function(name))
168
171
  elif name in MicroSeries.AGNOSTIC_FUNCTIONS:
169
172
  setattr(self, name, self._create_agnostic_function(name))
170
173
 
174
+ def sum(
175
+ self,
176
+ axis: Optional[Union[int, str]] = 0,
177
+ skipna: bool = True,
178
+ numeric_only: bool = False,
179
+ min_count: int = 0,
180
+ **kwargs,
181
+ ) -> Union[pd.Series, MicroSeries, float]:
182
+ """Sum numeric columns, weighting reductions across observations.
183
+
184
+ Column sums (axis=0 or 'index') apply observation weights and return a
185
+ plain Series. Row sums (axis=1 or 'columns') do not multiply row values
186
+ by weights; they return a MicroSeries with an independent copy of the
187
+ original weights for subsequent weighted aggregation.
188
+
189
+ Non-numeric columns are excluded, matching other MicroDataFrame
190
+ aggregations. skipna and min_count follow pandas sum semantics.
191
+ Explicit axis=None follows the installed pandas version: column sums in
192
+ pandas 2, and a weighted total over both axes in pandas 3.
193
+ """
194
+ axis_number = None if axis is None else self._get_axis_number(axis)
195
+ values = pd.DataFrame(self)
196
+ numeric_columns = [
197
+ pd.api.types.is_numeric_dtype(dtype) for dtype in values.dtypes
198
+ ]
199
+ values = values.iloc[:, numeric_columns]
200
+ if axis_number != 1 and self.weights is not None:
201
+ values = values.mul(self.weights, axis=0)
202
+ result = values.sum(
203
+ axis=axis,
204
+ skipna=skipna,
205
+ numeric_only=numeric_only,
206
+ min_count=min_count,
207
+ **kwargs,
208
+ )
209
+ if axis_number == 1:
210
+ weights = (
211
+ self.weights.copy()
212
+ if self.weights is not None
213
+ else pd.Series(1.0, index=self.index)
214
+ )
215
+ return MicroSeries(result, weights=weights)
216
+ return result
217
+
171
218
  def _create_scalar_function(self, name: str) -> Callable:
172
219
  """Create a scalar function that returns a Series of results.
173
220
 
microdf/microseries.py CHANGED
@@ -187,16 +187,38 @@ class MicroSeries(pd.Series):
187
187
  :returns: A Series multiplying the MicroSeries by its weight.
188
188
  :rtype: pd.Series
189
189
  """
190
- return self.multiply(self.weights)
190
+ return pd.Series(self, copy=False).multiply(self.weights)
191
191
 
192
192
  @scalar_function
193
- def sum(self) -> float:
193
+ def sum(
194
+ self,
195
+ axis: Optional[Union[int, str]] = 0,
196
+ skipna: bool = True,
197
+ numeric_only: bool = False,
198
+ min_count: int = 0,
199
+ **kwargs,
200
+ ) -> float:
194
201
  """Calculates the weighted sum of the MicroSeries.
195
202
 
203
+ axis may be 0, 'index' or None, as for pandas Series.sum. skipna,
204
+ numeric_only and min_count are applied to the weighted values;
205
+ min_count counts valid observations, not the sum of their weights.
206
+
196
207
  :returns: The weighted sum.
197
208
  :rtype: float
198
209
  """
199
- return self.multiply(self.weights).sum()
210
+ # Keep the intermediate unweighted so subclass constructors cannot
211
+ # apply observation weights a second time during the final reduction.
212
+ values = pd.Series(self)
213
+ if not self.empty:
214
+ values = values.multiply(self.weights)
215
+ return values.sum(
216
+ axis=axis,
217
+ skipna=skipna,
218
+ numeric_only=numeric_only,
219
+ min_count=min_count,
220
+ **kwargs,
221
+ )
200
222
 
201
223
  @scalar_function
202
224
  def count(self, skipna: bool = True) -> float:
@@ -0,0 +1,216 @@
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_sum_axis_1() -> None:
11
+ # Test basic row-wise sum
12
+ df = mdf.MicroDataFrame(
13
+ {"A": [1, 2, 3], "B": [4, 5, 6], "C": [7, 8, 9]},
14
+ weights=[0.5, 1.0, 2.0],
15
+ )
16
+
17
+ # Row-wise sum (axis=1) should not use weights
18
+ row_sums = df.sum(axis=1)
19
+ expected = pd.Series([12, 15, 18], index=df.index) # 1+4+7, 2+5+8, 3+6+9
20
+ pd.testing.assert_series_equal(pd.Series(row_sums), expected)
21
+
22
+ # Column-wise sum (axis=0) should use weights
23
+ col_sums = df.sum(axis=0)
24
+ expected_weighted = pd.Series(
25
+ {
26
+ "A": 1 * 0.5 + 2 * 1.0 + 3 * 2.0, # 8.5
27
+ "B": 4 * 0.5 + 5 * 1.0 + 6 * 2.0, # 19.0
28
+ "C": 7 * 0.5 + 8 * 1.0 + 9 * 2.0, # 29.5
29
+ }
30
+ )
31
+ pd.testing.assert_series_equal(col_sums, expected_weighted)
32
+
33
+ # Test with mixed types (non-numeric columns should be ignored)
34
+ df_mixed = mdf.MicroDataFrame(
35
+ {"A": [1, 2, 3], "B": [4, 5, 6], "text": ["a", "b", "c"]},
36
+ weights=[1, 1, 1],
37
+ )
38
+
39
+ row_sums_mixed = df_mixed.sum(axis=1)
40
+ expected_mixed = pd.Series([5, 7, 9], index=df_mixed.index) # Only A+B
41
+ pd.testing.assert_series_equal(pd.Series(row_sums_mixed), expected_mixed)
42
+
43
+ # Test with axis='columns' (string form)
44
+ row_sums_str = df.sum(axis="columns")
45
+ pd.testing.assert_series_equal(pd.Series(row_sums_str), expected)
46
+
47
+ # Test with additional parameters
48
+ df_with_nan = mdf.MicroDataFrame(
49
+ {"A": [1, np.nan, 3], "B": [4, 5, 6], "C": [7, 8, np.nan]},
50
+ weights=[1, 1, 1],
51
+ )
52
+
53
+ # skipna=True (default)
54
+ row_sums_skipna = df_with_nan.sum(axis=1)
55
+ expected_skipna = pd.Series([12.0, 13.0, 9.0]) # NaN values skipped
56
+ pd.testing.assert_series_equal(pd.Series(row_sums_skipna), expected_skipna)
57
+
58
+ # skipna=False
59
+ row_sums_no_skipna = df_with_nan.sum(axis=1, skipna=False)
60
+ expected_no_skipna = pd.Series([12.0, np.nan, np.nan]) # NaN propagates
61
+ pd.testing.assert_series_equal(pd.Series(row_sums_no_skipna), expected_no_skipna)
62
+
63
+ # Test min_count parameter
64
+ row_sums_min_count = df_with_nan.sum(axis=1, min_count=3)
65
+ expected_min_count = pd.Series(
66
+ [12.0, np.nan, np.nan]
67
+ ) # Row 1 and 2 have < 3 non-NA values
68
+ pd.testing.assert_series_equal(pd.Series(row_sums_min_count), expected_min_count)
69
+
70
+
71
+ @pytest.mark.parametrize("axis", [0, "index", 1, "columns"])
72
+ @pytest.mark.parametrize("positional", [False, True])
73
+ def test_sum_binds_positional_and_keyword_axes(axis, positional):
74
+ frame = mdf.MicroDataFrame(
75
+ {"a": [1, 2, 3], "b": [4, 5, 6]}, index=[7, 8, 9], weights=[1, 2, 3]
76
+ )
77
+ result = frame.sum(axis) if positional else frame.sum(axis=axis)
78
+ if axis in (0, "index"):
79
+ assert type(result) is pd.Series
80
+ pd.testing.assert_series_equal(result, pd.Series({"a": 14.0, "b": 32.0}))
81
+ else:
82
+ assert isinstance(result, mdf.MicroSeries)
83
+ pd.testing.assert_series_equal(
84
+ pd.Series(result), pd.Series([5, 7, 9], index=frame.index)
85
+ )
86
+ pd.testing.assert_series_equal(result.weights, frame.weights)
87
+ # Row values are not weighted yet; subsequent aggregation is weighted.
88
+ assert result.sum() == 5 * 1 + 7 * 2 + 9 * 3
89
+ result.weights.iloc[0] = 100
90
+ assert frame.weights.iloc[0] == 1
91
+
92
+
93
+ @pytest.mark.parametrize("skipna,min_count", [(True, 0), (False, 0), (True, 3)])
94
+ @pytest.mark.parametrize("axis", [0, "index", None])
95
+ def test_weighted_column_sum_options(axis, skipna, min_count):
96
+ raw = pd.DataFrame({"a": [1.0, np.nan, 3.0], "b": [4.0, 5.0, 6.0]})
97
+ weights = pd.Series([1.0, 2.0, 3.0])
98
+ frame = mdf.MicroDataFrame(raw, weights=weights)
99
+ # Native sum defines version-specific axis=None and missing-value behavior.
100
+ # The independently weighted entries are [1, NaN, 9] and [4, 10, 18].
101
+ expected_data = pd.DataFrame({"a": [1.0, np.nan, 9.0], "b": [4.0, 10.0, 18.0]})
102
+ with warnings.catch_warnings():
103
+ warnings.simplefilter("ignore", FutureWarning)
104
+ expected = expected_data.sum(axis=axis, skipna=skipna, min_count=min_count)
105
+ actual = frame.sum(axis=axis, skipna=skipna, min_count=min_count)
106
+ if isinstance(expected, pd.Series):
107
+ assert type(actual) is pd.Series
108
+ pd.testing.assert_series_equal(actual, expected)
109
+ else:
110
+ np.testing.assert_allclose(actual, expected, equal_nan=True)
111
+
112
+
113
+ @pytest.mark.parametrize("axis", [None, 0, "index"])
114
+ @pytest.mark.parametrize("skipna,min_count", [(True, 0), (False, 0), (True, 3)])
115
+ def test_microseries_sum_options(axis, skipna, min_count):
116
+ series = mdf.MicroSeries([1.0, np.nan, 3.0], index=[7, 8, 9], weights=[1, 2, 3])
117
+ expected = pd.Series([1.0, np.nan, 9.0]).sum(
118
+ axis=axis, skipna=skipna, min_count=min_count
119
+ )
120
+ np.testing.assert_allclose(
121
+ series.sum(axis, skipna=skipna, min_count=min_count), expected, equal_nan=True
122
+ )
123
+
124
+
125
+ @pytest.mark.parametrize("min_count,expected", [(0, 0.0), (1, np.nan)])
126
+ def test_empty_numeric_row_sum_identity(min_count, expected):
127
+ frame = mdf.MicroDataFrame({"text": ["a", "b"]}, index=[7, 8], weights=[2, 3])
128
+ actual = frame.sum(axis=1, min_count=min_count)
129
+ assert isinstance(actual, mdf.MicroSeries)
130
+ pd.testing.assert_series_equal(
131
+ pd.Series(actual), pd.Series([expected, expected], index=frame.index)
132
+ )
133
+ pd.testing.assert_series_equal(actual.weights, frame.weights)
134
+
135
+
136
+ def test_sum_rejects_invalid_arguments():
137
+ frame = mdf.MicroDataFrame({"a": [1, 2]}, weights=[1, 2])
138
+ with pytest.raises(TypeError):
139
+ frame.sum(1, axis=0)
140
+ with pytest.raises(TypeError):
141
+ frame.sum(bogus=True)
142
+ with pytest.raises(ValueError):
143
+ frame.sum(axis=2)
144
+ with pytest.raises(ValueError):
145
+ frame["a"].sum(axis=1)
146
+
147
+
148
+ def test_sum_handles_boolean_and_nullable_numeric_columns():
149
+ raw = pd.DataFrame(
150
+ {
151
+ "count": pd.Series([1, None, 3], dtype="Int64"),
152
+ "flag": pd.Series([True, False, True], dtype="boolean"),
153
+ "text": ["a", "b", "c"],
154
+ }
155
+ )
156
+ frame = mdf.MicroDataFrame(raw, weights=[1, 2, 3])
157
+ expected = raw[["count", "flag"]].sum(axis=1)
158
+ pd.testing.assert_series_equal(pd.Series(frame.sum(1)), expected)
159
+ totals = frame.sum(numeric_only=True)
160
+ assert list(totals.index) == ["count", "flag"]
161
+ assert totals["count"] == 10
162
+ assert totals["flag"] == 4
163
+
164
+
165
+ def test_sum_preserves_other_scalar_positional_arguments():
166
+ frame = mdf.MicroDataFrame({"a": [-1.0, 2.0, 3.0]}, weights=[1, 2, 3])
167
+ for method, argument in [
168
+ ("gini", "shift"),
169
+ ("top_x_pct_share", 0.25),
170
+ ("mean", False),
171
+ ("var", 0),
172
+ ]:
173
+ actual = getattr(frame, method)(argument)["a"]
174
+ expected = getattr(frame["a"], method)(argument)
175
+ assert actual == expected
176
+
177
+
178
+ @pytest.mark.parametrize("min_count,expected", [(0, 0.0), (1, np.nan)])
179
+ def test_sum_of_empty_inputs(min_count, expected):
180
+ frame = mdf.MicroDataFrame(pd.DataFrame({"a": pd.Series([], dtype=float)}))
181
+ row_sums = frame.sum(axis=1, min_count=min_count)
182
+ assert isinstance(row_sums, mdf.MicroSeries)
183
+ assert row_sums.empty
184
+ pd.testing.assert_series_equal(row_sums.weights, pd.Series([], dtype=float))
185
+ pd.testing.assert_series_equal(
186
+ frame.sum(min_count=min_count), pd.Series({"a": expected})
187
+ )
188
+ series = mdf.MicroSeries([], dtype=float)
189
+ np.testing.assert_allclose(
190
+ series.sum(min_count=min_count), expected, equal_nan=True
191
+ )
192
+ with pytest.raises(ValueError):
193
+ series.sum(axis=1)
194
+
195
+
196
+ @pytest.mark.parametrize("axis", [0, 1])
197
+ @pytest.mark.parametrize("mixed_dtypes", [False, True])
198
+ def test_sum_preserves_duplicate_numeric_column_labels(axis, mixed_dtypes):
199
+ if mixed_dtypes:
200
+ raw = pd.DataFrame([[1.0, "x", 4.0], [2.0, "y", 5.0]], columns=["a", "a", "a"])
201
+ else:
202
+ raw = pd.DataFrame([[1.0, 4.0], [2.0, 5.0]], columns=["a", "a"])
203
+ frame = mdf.MicroDataFrame(raw, weights=[2, 3])
204
+
205
+ result = frame.sum(axis)
206
+
207
+ if axis == 0:
208
+ assert type(result) is pd.Series
209
+ expected = pd.Series([8.0, 23.0], index=["a", "a"])
210
+ pd.testing.assert_series_equal(result, expected)
211
+ else:
212
+ assert isinstance(result, mdf.MicroSeries)
213
+ pd.testing.assert_series_equal(pd.Series(result), pd.Series([5.0, 7.0]))
214
+ pd.testing.assert_series_equal(result.weights, frame.weights)
215
+ assert result.sum() == 31.0
216
+ pd.testing.assert_frame_equal(pd.DataFrame(frame), raw)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microdf-python
3
- Version: 1.3.8
3
+ Version: 1.3.10
4
4
  Summary: Weighted pandas DataFrames and Series for survey microdata
5
5
  Author-email: Max Ghenis <max@policyengine.org>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  microdf/__init__.py,sha256=sddmTcTZFSb1fjZkoLUR5TK7r5PEld4v2F92xBI5Sxs,641
2
- microdf/microdataframe.py,sha256=mmWlwiXOfBLikCNeMTqXPkPlGu_l0cx1fGNwvLjnflU,42528
3
- microdf/microseries.py,sha256=W_6FY6iTfdsyfVwE9KreTE-38SSm6ADmeA7YTAKzD2Q,37332
2
+ microdf/microdataframe.py,sha256=yqLUC43WBDT8Z-AQ7OfjKUoizehcUGBHgt7wg_BVlFI,44416
3
+ microdf/microseries.py,sha256=uP4KOKluLsSFHRZf8aAyKKP4GvnXP6cyMFksncgowYU,38143
4
4
  microdf/tests/conftest.py,sha256=u-EMyX1-u_nM-YO0RJYCzYHQDXxUI2WQE6GkyJlErqg,150
5
5
  microdf/tests/test_aggregation_errors.py,sha256=9jJDiEyxMb2z1Zmj-o8AHDNp8LOputbEkAMefifnWaE,2013
6
6
  microdf/tests/test_dataframe_weight_storage.py,sha256=ngIsWa_QcBnnaLpAyIZhTgMxjv_7cK8Nbf7f4n29R4Q,1975
@@ -9,9 +9,10 @@ microdf/tests/test_nullify_weights_index.py,sha256=kZgzMaZEa_PXbsor2S4E-6VRid3C3
9
9
  microdf/tests/test_pandas3_compatibility.py,sha256=A34Ni_WQ303sSNv-sqv5CGAQp54zj-ZSGAPEBHZslNI,8573
10
10
  microdf/tests/test_quantile_missing_values.py,sha256=lfntDvV2q7KH_CPVrXFJSQFoaGlc_OkRGhKwpxtDQtY,5327
11
11
  microdf/tests/test_serialization.py,sha256=a7pHL2hNiG5iJjRtfx3C1BCmgOZouAekiOwUxouAPfo,5083
12
+ microdf/tests/test_sum_axes.py,sha256=N05ocwI5lLv2OgoaovRIqFIae-70356kZemRRet0ac8,8521
12
13
  microdf/tests/test_version_metadata.py,sha256=M1EabzHLKZZw3Djd6Zu2UuMQtDLV6rZ1zDrOU7W_jf0,227
13
- microdf_python-1.3.8.dist-info/licenses/LICENSE,sha256=uPs-ASYnzlldpf2z8jeRgQFeEH3FLhSuX0rw0OKWoDU,1067
14
- microdf_python-1.3.8.dist-info/METADATA,sha256=l1xFpkxbUkg7I8CzzZ4X1qC6aOJqnNGx4dvErboT72A,2305
15
- microdf_python-1.3.8.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
- microdf_python-1.3.8.dist-info/top_level.txt,sha256=T2WFPTygQQMdS3GF8YpZ12DKfMGrspbZ3r7z-e3KfiM,8
17
- microdf_python-1.3.8.dist-info/RECORD,,
14
+ microdf_python-1.3.10.dist-info/licenses/LICENSE,sha256=uPs-ASYnzlldpf2z8jeRgQFeEH3FLhSuX0rw0OKWoDU,1067
15
+ microdf_python-1.3.10.dist-info/METADATA,sha256=ozvlZARMKjPYVIMiJZdTL6wgGi_SLv0GKLtLsqgdR_M,2306
16
+ microdf_python-1.3.10.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
17
+ microdf_python-1.3.10.dist-info/top_level.txt,sha256=T2WFPTygQQMdS3GF8YpZ12DKfMGrspbZ3r7z-e3KfiM,8
18
+ microdf_python-1.3.10.dist-info/RECORD,,