microdf-python 1.2.3__tar.gz → 1.3.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.2.3
3
+ Version: 1.3.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
@@ -50,13 +50,12 @@ import microdf as mdf
50
50
  import pandas as pd
51
51
 
52
52
  # Create sample data with weights
53
- df = pd.DataFrame({
54
- 'income': [10_000, 20_000, 30_000, 40_000, 50_000],
55
- 'weights': [1, 2, 3, 2, 1]
56
- })
53
+ df = pd.DataFrame(
54
+ {"income": [10_000, 20_000, 30_000, 40_000, 50_000], "weights": [1, 2, 3, 2, 1]}
55
+ )
57
56
 
58
57
  # Create a MicroDataFrame
59
- mdf_df = mdf.MicroDataFrame(df, weights='weights')
58
+ mdf_df = mdf.MicroDataFrame(df, weights="weights")
60
59
 
61
60
  # All operations are weight-aware
62
61
  print(mdf_df.income.mean()) # Weighted mean
@@ -29,13 +29,12 @@ import microdf as mdf
29
29
  import pandas as pd
30
30
 
31
31
  # Create sample data with weights
32
- df = pd.DataFrame({
33
- 'income': [10_000, 20_000, 30_000, 40_000, 50_000],
34
- 'weights': [1, 2, 3, 2, 1]
35
- })
32
+ df = pd.DataFrame(
33
+ {"income": [10_000, 20_000, 30_000, 40_000, 50_000], "weights": [1, 2, 3, 2, 1]}
34
+ )
36
35
 
37
36
  # Create a MicroDataFrame
38
- mdf_df = mdf.MicroDataFrame(df, weights='weights')
37
+ mdf_df = mdf.MicroDataFrame(df, weights="weights")
39
38
 
40
39
  # All operations are weight-aware
41
40
  print(mdf_df.income.mean()) # Weighted mean
@@ -96,8 +96,10 @@ class _MicroILocIndexer:
96
96
 
97
97
  class MicroDataFrame(pd.DataFrame):
98
98
  def __init__(self, *args, weights=None, **kwargs):
99
- """A DataFrame-inheriting class for weighted microdata. Weights can be
100
- provided at initialisation, or using set_weights or set_weight_col.
99
+ """A DataFrame-inheriting class for weighted microdata.
100
+
101
+ Weights can be provided at initialisation, or using set_weights or
102
+ set_weight_col.
101
103
 
102
104
  :param weights: Array of weights.
103
105
  :type weights: np.array
@@ -231,8 +233,9 @@ class MicroDataFrame(pd.DataFrame):
231
233
  return fn
232
234
 
233
235
  def get_args_as_micro_series(*kwarg_names: tuple) -> Callable:
234
- """Decorator for auto-parsing column names into MicroSeries objects. If
235
- given, kwarg_names limits arguments checked to keyword arguments
236
+ """Decorator for auto-parsing column names into MicroSeries objects.
237
+
238
+ If given, kwarg_names limits arguments checked to keyword arguments
236
239
  specified.
237
240
 
238
241
  :param arg_names: argument names to restrict to.
@@ -292,8 +295,10 @@ class MicroDataFrame(pd.DataFrame):
292
295
  weights: Union[np.ndarray, str],
293
296
  preserve_old: Optional[bool] = False,
294
297
  ) -> None:
295
- """Sets the weights for the MicroDataFrame. If a string is received, it
296
- will be assumed to be the column name of the weight column.
298
+ """Sets the weights for the MicroDataFrame.
299
+
300
+ If a string is received, it will be assumed to be the column name of
301
+ the weight column.
297
302
 
298
303
  :param weights: Array of weights.
299
304
  :param preserve_old: If True, keeps the old weights as a column when
@@ -305,7 +310,11 @@ class MicroDataFrame(pd.DataFrame):
305
310
 
306
311
  if isinstance(weights, str):
307
312
  self.weights_col = weights
308
- self.weights = pd.Series(self[weights], dtype=float)
313
+ self.weights = pd.Series(
314
+ np.asarray(self[weights]),
315
+ index=self.index,
316
+ dtype=float,
317
+ )
309
318
  self._link_all_weights()
310
319
  elif weights is not None:
311
320
  if len(weights) != len(self):
@@ -314,9 +323,18 @@ class MicroDataFrame(pd.DataFrame):
314
323
  f"length of DataFrame ({len(self)})."
315
324
  )
316
325
  self.weights_col = None
326
+ # Align weights to self.index. Without this, weighted ops
327
+ # (self[col].multiply(self.weights) in .sum()) align on
328
+ # label, so any non-default index silently produces all-NaN
329
+ # and aggregations collapse to 0. If a Series is passed in,
330
+ # strip its index so we position-align to self.index.
331
+ if isinstance(weights, pd.Series):
332
+ weights = weights.values
317
333
  with warnings.catch_warnings():
318
334
  warnings.filterwarnings("ignore", category=UserWarning)
319
- self.weights = pd.Series(weights, dtype=float)
335
+ self.weights = pd.Series(
336
+ np.asarray(weights), index=self.index, dtype=float
337
+ )
320
338
  self._link_all_weights()
321
339
 
322
340
  def set_weight_col(self, column: str, preserve_old: Optional[bool] = False) -> None:
@@ -416,8 +434,9 @@ class MicroDataFrame(pd.DataFrame):
416
434
  :return: MicroDataFrame with reset index or None if inplace=True.
417
435
  """
418
436
  if inplace:
419
- weights_backup = self.weights.copy()
420
- # Perform in-place reset on the parent DataFrame
437
+ # Snapshot weight *values* positionally — the index is about
438
+ # to change and reset_index preserves row order.
439
+ weight_values = np.asarray(self.weights.values, dtype=float)
421
440
  super().reset_index(
422
441
  level=level,
423
442
  drop=drop,
@@ -427,7 +446,7 @@ class MicroDataFrame(pd.DataFrame):
427
446
  allow_duplicates=allow_duplicates,
428
447
  names=names,
429
448
  )
430
- self.weights = weights_backup
449
+ self.weights = pd.Series(weight_values, index=self.index, dtype=float)
431
450
  self._link_all_weights()
432
451
  return None
433
452
  else:
@@ -440,13 +459,22 @@ class MicroDataFrame(pd.DataFrame):
440
459
  allow_duplicates=allow_duplicates,
441
460
  names=names,
442
461
  )
443
- return MicroDataFrame(res, weights=self.weights)
462
+ out = MicroDataFrame(res, weights=self.weights.values)
463
+ # Ensure weights align to res.index (reset_index changes the
464
+ # index but preserves row order, so pass values positionally).
465
+ out.weights = pd.Series(
466
+ np.asarray(self.weights.values, dtype=float),
467
+ index=out.index,
468
+ dtype=float,
469
+ )
470
+ return out
444
471
 
445
472
  def copy(self, deep: Optional[bool] = True) -> "MicroDataFrame":
446
473
  res = super().copy(deep)
447
- # This changes the original columns to Series. Undo it:
448
- for col in self.columns:
449
- self[col] = MicroSeries(self[col])
474
+ # super().copy() corrupts self's column types to plain Series.
475
+ # Restore them in O(N) instead of O(N²) by calling
476
+ # _link_all_weights once rather than per-column __setitem__.
477
+ self._link_all_weights()
450
478
  res = MicroDataFrame(res, weights=self.weights.copy(deep))
451
479
  return res
452
480
 
@@ -480,8 +508,11 @@ class MicroDataFrame(pd.DataFrame):
480
508
  dropped.
481
509
  :return: MicroDataFrame or None if inplace=True.
482
510
  """
511
+ row_drop = axis in (0, "index") or index is not None
483
512
  if inplace:
484
- weights_backup = self.weights.copy()
513
+ # Snapshot the pre-drop weights keyed by the pre-drop index so
514
+ # we can reindex to the surviving rows after the drop.
515
+ pre_drop_weights = pd.Series(self.weights.values, index=self.index.copy())
485
516
  # Perform in-place drop on the parent DataFrame
486
517
  super().drop(
487
518
  labels=labels,
@@ -492,7 +523,15 @@ class MicroDataFrame(pd.DataFrame):
492
523
  inplace=True,
493
524
  errors=errors,
494
525
  )
495
- self.weights = weights_backup
526
+ if row_drop:
527
+ surviving = pre_drop_weights.reindex(self.index)
528
+ self.weights = pd.Series(
529
+ surviving.values, index=self.index, dtype=float
530
+ )
531
+ else:
532
+ self.weights = pd.Series(
533
+ pre_drop_weights.values, index=self.index, dtype=float
534
+ )
496
535
  self._link_all_weights()
497
536
  return None
498
537
  else:
@@ -505,7 +544,19 @@ class MicroDataFrame(pd.DataFrame):
505
544
  inplace=False,
506
545
  errors=errors,
507
546
  )
508
- return MicroDataFrame(res, weights=self.weights)
547
+ if row_drop:
548
+ # Row drop: keep only the weights for surviving rows,
549
+ # in the order of the resulting DataFrame.
550
+ pre_drop_weights = pd.Series(self.weights.values, index=self.index)
551
+ new_weights = pre_drop_weights.reindex(res.index).values
552
+ else:
553
+ new_weights = self.weights.values
554
+ out = MicroDataFrame(res, weights=new_weights)
555
+ # Guard against the set_weights path building weights with a
556
+ # default RangeIndex, which would misalign against res.index
557
+ # and silently zero weighted aggregations.
558
+ out.weights = pd.Series(new_weights, index=out.index, dtype=float)
559
+ return out
509
560
 
510
561
  def merge(
511
562
  self,
@@ -548,7 +599,17 @@ class MicroDataFrame(pd.DataFrame):
548
599
  :param validate: If specified, checks if merge is of specified type.
549
600
  :return: MicroDataFrame with merged data.
550
601
  """
551
- res = super().merge(
602
+ # Attach the left weights as a temporary column so pandas' merge
603
+ # propagates them onto every surviving output row (including
604
+ # many-to-many row duplications, inner-join filtering, and
605
+ # left-with-missing NaNs). We then strip the column back off.
606
+ tmp = "__microdf_weights__"
607
+ # Avoid clobbering if this exact name is already used.
608
+ while tmp in self.columns or tmp in right.columns:
609
+ tmp += "_"
610
+ left_df = pd.DataFrame(self).copy()
611
+ left_df[tmp] = np.asarray(self.weights.values, dtype=float)
612
+ res = left_df.merge(
552
613
  right,
553
614
  how=how,
554
615
  on=on,
@@ -562,11 +623,17 @@ class MicroDataFrame(pd.DataFrame):
562
623
  indicator=indicator,
563
624
  validate=validate,
564
625
  )
565
-
566
- # For inner join, both dataframes must have the same weights on
567
- # matching rows. For now, we'll use the left dataframe's weights.
568
- # This is a simplification and may need more sophisticated handling
569
- return MicroDataFrame(res, weights=self.weights)
626
+ # Pull out the propagated weights. Rows with no left match in a
627
+ # right/outer join get NaN weight fill with 0 so they don't
628
+ # poison later aggregations (a user who needs a different
629
+ # convention can override afterwards).
630
+ merged_weights = res[tmp].fillna(0).to_numpy(dtype=float)
631
+ res = res.drop(columns=[tmp])
632
+ out = MicroDataFrame(res, weights=merged_weights)
633
+ # Ensure the weights Series aligns with res.index regardless of
634
+ # the default-RangeIndex behavior of set_weights.
635
+ out.weights = pd.Series(merged_weights, index=out.index, dtype=float)
636
+ return out
570
637
 
571
638
  def __getattr__(self, name):
572
639
  """Allow accessing columns as attributes (e.g., df.column_name).
@@ -598,10 +665,16 @@ class MicroDataFrame(pd.DataFrame):
598
665
  return: DataFrameGroupBy object with columns using weights
599
666
  rtype: DataFrameGroupBy
600
667
  """
601
- self["__tmp_weights"] = self.weights
602
- gb = super().groupby(by, *args, **kwargs)
668
+ # Build the groupby on a *copy* that carries a ``__tmp_weights``
669
+ # column. We used to set this column on ``self`` directly, which
670
+ # permanently leaked the weight column onto the caller's
671
+ # DataFrame — any later ``df.sum()`` or ``list(df.columns)``
672
+ # would then include it.
673
+ staged = pd.DataFrame(self).copy()
674
+ staged["__tmp_weights"] = np.asarray(self.weights.values, dtype=float)
675
+ gb = staged.groupby(by, *args, **kwargs)
603
676
  weights = copy.deepcopy(gb["__tmp_weights"])
604
- for col in self.columns: # df.groupby(...)[col]s use weights
677
+ for col in staged.columns: # df.groupby(...)[col]s use weights
605
678
  res = gb[col]
606
679
  res.__class__ = MicroSeriesGroupBy
607
680
  res._init()
@@ -9,10 +9,51 @@ import pandas as pd
9
9
  logger = logging.getLogger(__name__)
10
10
 
11
11
 
12
+ def _weighted_top_share(
13
+ values: np.ndarray, weights: np.ndarray, top_x_pct: float
14
+ ) -> float:
15
+ """Share of the sum held by the top ``top_x_pct`` of weight.
16
+
17
+ Sort by value ascending, cumulate the weight, pick the slice from the top
18
+ that covers exactly ``top_x_pct`` of total weight, and distribute the tied-
19
+ at-cutoff row proportionally so constant values return exactly
20
+ ``top_x_pct`` rather than 1.0.
21
+ """
22
+ if top_x_pct <= 0:
23
+ return 0.0
24
+ if top_x_pct >= 1:
25
+ return 1.0
26
+ total_weight = weights.sum()
27
+ total_sum = float((values * weights).sum())
28
+ if total_weight == 0 or total_sum == 0:
29
+ return np.nan
30
+ # Ascending sort; the "top" cutoff is the final ``top_x_pct`` of
31
+ # cumulative weight.
32
+ order = np.argsort(values, kind="mergesort")
33
+ v = values[order]
34
+ w = weights[order]
35
+ # Cumulative weight from the bottom up.
36
+ cum_w = np.cumsum(w)
37
+ target_bottom_weight = total_weight * (1.0 - top_x_pct)
38
+ # searchsorted(cum_w, target, side="right") gives the first index
39
+ # whose cumulative weight exceeds the bottom cutoff.
40
+ k = int(np.searchsorted(cum_w, target_bottom_weight, side="right"))
41
+ # Rows strictly above the cutoff contribute all of their weight.
42
+ if k >= len(v):
43
+ return 0.0
44
+ top_sum = float((v[k + 1 :] * w[k + 1 :]).sum())
45
+ # Row k straddles the cutoff; include the fraction of its weight
46
+ # that lies above the cutoff so ties don't double-count.
47
+ partial_weight = cum_w[k] - target_bottom_weight
48
+ top_sum += float(v[k] * partial_weight)
49
+ return top_sum / total_sum
50
+
51
+
12
52
  class MicroSeries(pd.Series):
13
53
  def __init__(self, *args, weights: np.array = None, **kwargs):
14
- """A Series-inheriting class for weighted microdata. Weights can be
15
- provided at initialisation, or using set_weights.
54
+ """A Series-inheriting class for weighted microdata.
55
+
56
+ Weights can be provided at initialisation, or using set_weights.
16
57
 
17
58
  :param weights: Array of weights.
18
59
  :type weights: np.array
@@ -64,23 +105,13 @@ class MicroSeries(pd.Series):
64
105
  )
65
106
  return super().to_numpy(*args, **kwargs)
66
107
 
67
- def weighted_function(fn: Callable) -> Callable:
68
- @wraps(fn)
69
- def safe_fn(*args, **kwargs):
70
- try:
71
- return fn(*args, **kwargs)
72
- except ZeroDivisionError:
73
- return np.NaN
74
-
75
- return safe_fn
76
-
77
- @weighted_function
78
108
  def scalar_function(fn: Callable) -> Callable:
109
+ """Decorator marking ``fn`` as returning a scalar (float)."""
79
110
  fn._rtype = float
80
111
  return fn
81
112
 
82
- @weighted_function
83
113
  def vector_function(fn: Callable) -> Callable:
114
+ """Decorator marking ``fn`` as returning a pandas Series."""
84
115
  fn._rtype = pd.Series
85
116
  return fn
86
117
 
@@ -96,7 +127,11 @@ class MicroSeries(pd.Series):
96
127
  """
97
128
  if weights is None:
98
129
  if len(self) > 0:
99
- self.weights = pd.Series(np.ones_like(self._values), dtype=float)
130
+ self.weights = pd.Series(
131
+ np.ones_like(self._values),
132
+ index=self.index,
133
+ dtype=float,
134
+ )
100
135
  else:
101
136
  if len(weights) != len(self):
102
137
  raise ValueError(
@@ -107,7 +142,14 @@ class MicroSeries(pd.Series):
107
142
  if preserve_old and self.weights is not None:
108
143
  self["old_weights"] = self.weights
109
144
 
110
- self.weights = pd.Series(weights, dtype=float)
145
+ # Align weights to self.index so element-wise operations such
146
+ # as self.multiply(self.weights) (used by .sum(), .weight())
147
+ # don't silently produce all-NaN when the caller uses a
148
+ # non-default index. If a pandas Series is passed in, strip
149
+ # its index first so we position-align rather than label-align.
150
+ if isinstance(weights, pd.Series):
151
+ weights = weights.values
152
+ self.weights = pd.Series(np.asarray(weights), index=self.index, dtype=float)
111
153
 
112
154
  def nullify_weights(self) -> None:
113
155
  """Set all weights to 1, effectively making the Series unweighted.
@@ -136,12 +178,22 @@ class MicroSeries(pd.Series):
136
178
  return self.multiply(self.weights).sum()
137
179
 
138
180
  @scalar_function
139
- def count(self) -> float:
181
+ def count(self, skipna: bool = True) -> float:
140
182
  """Calculates the weighted count of the MicroSeries.
141
183
 
184
+ By default skips NaN values (matching pandas ``Series.count``).
185
+
186
+ :param skipna: Exclude NaN values (default True). If False, the
187
+ weighted count of every row is returned.
188
+ :type skipna: bool
142
189
  :returns: The weighted count.
190
+ :rtype: float
143
191
  """
144
- return self.weights.sum()
192
+ weights = np.asarray(self.weights.values, dtype=float)
193
+ if not skipna:
194
+ return float(weights.sum())
195
+ mask = ~pd.isna(self._values)
196
+ return float(weights[mask].sum())
145
197
 
146
198
  @scalar_function
147
199
  def mean(self, skipna: bool = True) -> float:
@@ -171,6 +223,89 @@ class MicroSeries(pd.Series):
171
223
 
172
224
  return np.average(values, weights=weights)
173
225
 
226
+ def _weighted_variance(self, ddof: int = 1, skipna: bool = True) -> float:
227
+ """Frequency-weighted variance.
228
+
229
+ Uses ``sum(w * (x - wmean)**2) / (sum(w) - ddof)``. With
230
+ ``ddof=0`` this is the population variance; with ``ddof=1`` it
231
+ is Bessel-corrected assuming the weights are frequency counts —
232
+ matching ``np.var(..., ddof=ddof)`` on a replicated sample.
233
+ """
234
+ values = np.asarray(self._values, dtype=float)
235
+ weights = np.asarray(self.weights.values, dtype=float)
236
+ if skipna:
237
+ mask = ~np.isnan(values)
238
+ values = values[mask]
239
+ weights = weights[mask]
240
+ elif np.isnan(values).any():
241
+ return np.nan
242
+ total_w = weights.sum()
243
+ if total_w == 0 or total_w - ddof <= 0:
244
+ return np.nan
245
+ mean = np.average(values, weights=weights)
246
+ return float((weights * (values - mean) ** 2).sum() / (total_w - ddof))
247
+
248
+ @scalar_function
249
+ def var(self, ddof: int = 1, skipna: bool = True) -> float:
250
+ """Calculates the weighted variance of the MicroSeries.
251
+
252
+ Treats weights as frequency counts (``sum(w) - ddof`` in the
253
+ denominator) so that with integer weights the result matches
254
+ ``np.var`` on the replicated sample.
255
+
256
+ :param ddof: Delta degrees of freedom (default 1).
257
+ :param skipna: Exclude NaN values (default True).
258
+ :returns: The weighted variance.
259
+ :rtype: float
260
+ """
261
+ return self._weighted_variance(ddof=ddof, skipna=skipna)
262
+
263
+ @scalar_function
264
+ def std(self, ddof: int = 1, skipna: bool = True) -> float:
265
+ """Calculates the weighted standard deviation of the MicroSeries.
266
+
267
+ :param ddof: Delta degrees of freedom (default 1).
268
+ :param skipna: Exclude NaN values (default True).
269
+ :returns: The weighted standard deviation.
270
+ :rtype: float
271
+ """
272
+ v = self._weighted_variance(ddof=ddof, skipna=skipna)
273
+ return float(np.sqrt(v)) if np.isfinite(v) else v
274
+
275
+ def cov(self, other, *args, **kwargs):
276
+ """Pandas ``cov`` — **unweighted**.
277
+
278
+ MicroSeries does not yet compute weighted covariance. Emits a
279
+ ``UserWarning`` so callers aren't silently given an unweighted number
280
+ after ``.sum()`` and ``.mean()`` worked as expected. See issue tracker
281
+ for a weighted implementation.
282
+ """
283
+ warnings.warn(
284
+ "MicroSeries.cov() falls through to pandas and is "
285
+ "unweighted. Use MicroSeries.var()/std() for weighted "
286
+ "second moments, or compute covariance manually with the "
287
+ "weights.",
288
+ UserWarning,
289
+ stacklevel=2,
290
+ )
291
+ return super().cov(other, *args, **kwargs)
292
+
293
+ def corr(self, other, *args, **kwargs):
294
+ """Pandas ``corr`` — **unweighted**.
295
+
296
+ MicroSeries does not yet compute weighted correlation. Emits a
297
+ ``UserWarning`` so callers aren't silently given an unweighted number.
298
+ See issue tracker for a weighted implementation.
299
+ """
300
+ warnings.warn(
301
+ "MicroSeries.corr() falls through to pandas and is "
302
+ "unweighted. Compute correlation manually with the weights "
303
+ "if you need the survey-weighted value.",
304
+ UserWarning,
305
+ stacklevel=2,
306
+ )
307
+ return super().corr(other, *args, **kwargs)
308
+
174
309
  def quantile(self, q: np.array) -> pd.Series:
175
310
  """Calculates weighted quantiles of the MicroSeries.
176
311
 
@@ -190,6 +325,20 @@ class MicroSeries(pd.Series):
190
325
  assert np.all(quantiles >= 0) and np.all(quantiles <= 1), (
191
326
  "quantiles should be in [0, 1]"
192
327
  )
328
+ # Drop zero-weight rows before sorting. Without this, q=0 (and
329
+ # internal plateaus of zero weight) picked a value with 0 weight
330
+ # that should have been skipped by the inverse CDF. E.g.
331
+ # MicroSeries([10, 20, 30], weights=[0, 1, 1]).quantile(0)
332
+ # returned 10 instead of 20.
333
+ nonzero = sample_weight > 0
334
+ if not nonzero.any():
335
+ return (
336
+ np.nan
337
+ if np.array(q).shape == ()
338
+ else pd.Series(np.full(len(quantiles), np.nan), index=quantiles)
339
+ )
340
+ values = values[nonzero]
341
+ sample_weight = sample_weight[nonzero]
193
342
  sorter = np.argsort(values)
194
343
  values = values[sorter]
195
344
  sample_weight = sample_weight[sorter]
@@ -218,58 +367,83 @@ class MicroSeries(pd.Series):
218
367
  def gini(self, negatives: Optional[str] = None) -> float:
219
368
  """Calculates Gini index.
220
369
 
221
- :param negatives: An optional string indicating how to treat negative
222
- values of x:
370
+ :param negatives: An optional string indicating how to treat
371
+ negative values of x:
223
372
  'zero' replaces negative values with zeroes.
224
373
  'shift' subtracts the minimum value from all values of x,
225
374
  when this minimum is negative. That is, it adds the absolute
226
375
  minimum value.
227
376
  Defaults to None, which leaves negative values as they are.
228
- :type q: str
377
+ :type negatives: str
229
378
  :returns: Gini index.
230
379
  :rtype: float
231
380
  """
232
381
  x = np.array(self).astype("float")
382
+ w = np.asarray(self.weights.values, dtype=float)
233
383
  if negatives == "zero":
234
- x[x < 0] = 0
235
- if negatives == "shift" and np.amin(x) < 0:
236
- x -= np.amin(x)
237
- if (self.weights != np.ones(len(self))).any(): # Varying weights.
238
- sorted_indices = np.argsort(self)
239
- sorted_x = np.array(self[sorted_indices])
240
- sorted_w = np.array(self.weights[sorted_indices])
241
- cumw = np.cumsum(sorted_w)
242
- cumxw = np.cumsum(sorted_x * sorted_w)
243
- return np.sum(cumxw[1:] * cumw[:-1] - cumxw[:-1] * cumw[1:]) / (
244
- cumxw[-1] * cumw[-1]
384
+ x = np.where(x < 0, 0.0, x)
385
+ elif negatives == "shift" and len(x) > 0 and np.amin(x) < 0:
386
+ x = x - np.amin(x)
387
+ elif negatives is not None:
388
+ raise ValueError(
389
+ f"Unknown negatives option {negatives!r}; expected "
390
+ "'zero', 'shift', or None."
245
391
  )
246
- else:
247
- sorted_x = np.sort(self)
248
- n = len(x)
249
- cumxw = np.cumsum(sorted_x)
250
- # The above formula, with all weights equal to 1 simplifies to:
251
- return (n + 1 - 2 * np.sum(cumxw) / cumxw[-1]) / n
392
+
393
+ if len(x) == 0:
394
+ return np.nan
395
+ if np.any(x < 0):
396
+ # The Lorenz-based formula assumes non-negative values; with
397
+ # negatives it can return values outside [0, 1].
398
+ warnings.warn(
399
+ "gini() called on data containing negative values; the "
400
+ "result is not guaranteed to lie in [0, 1]. Pass "
401
+ "negatives='zero' or negatives='shift' to handle them.",
402
+ UserWarning,
403
+ stacklevel=2,
404
+ )
405
+
406
+ # Short-circuit degenerate cases so we don't divide by zero.
407
+ total = float((x * w).sum())
408
+ if total == 0:
409
+ return 0.0
410
+
411
+ sorter = np.argsort(x, kind="mergesort")
412
+ sorted_x = x[sorter]
413
+ sorted_w = w[sorter]
414
+ cumw = np.cumsum(sorted_w)
415
+ cumxw = np.cumsum(sorted_x * sorted_w)
416
+ # Trapezoidal approximation of the area under the Lorenz curve.
417
+ return float(
418
+ np.sum(cumxw[1:] * cumw[:-1] - cumxw[:-1] * cumw[1:])
419
+ / (cumxw[-1] * cumw[-1])
420
+ )
252
421
 
253
422
  @scalar_function
254
423
  def top_x_pct_share(self, top_x_pct: float) -> float:
255
424
  """Calculates top x% share.
256
425
 
426
+ Uses a cumulative-weight sort so that rows tied at the cutoff
427
+ contribute proportionally rather than all-or-nothing. With
428
+ constant values this correctly returns ``top_x_pct`` itself.
429
+
257
430
  :param top_x_pct: Decimal between 0 and 1 of the top %, e.g. 0.1,
258
431
  0.001.
259
432
  :type top_x_pct: float
260
433
  :returns: The weighted share held by the top x%.
261
434
  :rtype: float
262
435
  """
263
- threshold = self.quantile(1 - top_x_pct)
264
- top_x_pct_sum = self[self >= threshold].sum()
265
- total_sum = self.sum()
266
- return top_x_pct_sum / total_sum
436
+ return _weighted_top_share(
437
+ np.asarray(self._values, dtype=float),
438
+ np.asarray(self.weights.values, dtype=float),
439
+ float(top_x_pct),
440
+ )
267
441
 
268
442
  @scalar_function
269
443
  def bottom_x_pct_share(self, bottom_x_pct: float) -> float:
270
444
  """Calculates bottom x% share.
271
445
 
272
- :param bottom_x_pct: Decimal between 0 and 1 of the top %, e.g. 0.1,
446
+ :param bottom_x_pct: Decimal between 0 and 1 of the bottom %, e.g. 0.1,
273
447
  0.001.
274
448
  :type bottom_x_pct: float
275
449
  :returns: The weighted share held by the bottom x%.
@@ -344,19 +518,44 @@ class MicroSeries(pd.Series):
344
518
 
345
519
  @vector_function
346
520
  def rank(self, pct: Optional[bool] = False) -> pd.Series:
347
- weights_sum = self.weights.values.sum()
521
+ """Weighted rank of each element.
522
+
523
+ Each element's rank is the cumulative weight of all values that are
524
+ less than or equal to it. Tied values therefore share the same rank, so
525
+ downstream bucketing (``decile_rank``, ``quintile_rank``, etc.) lands
526
+ tied rows in the same bucket.
527
+
528
+ :param pct: If True, divide ranks by the total weight so they lie in
529
+ ``(0, 1]``.
530
+ :type pct: bool
531
+ :returns: MicroSeries of ranks aligned to ``self``.
532
+ :rtype: MicroSeries
533
+ """
534
+ weights_sum = np.asarray(self.weights.values, dtype=float).sum()
348
535
  if weights_sum == 0:
349
536
  raise ZeroDivisionError(
350
537
  "Cannot calculate rank with zero total weight. "
351
- "All weights in the MicroSeries are zero, which would result "
352
- "in division by zero."
538
+ "All weights in the MicroSeries are zero, which would "
539
+ "result in division by zero."
353
540
  )
354
541
 
355
- order = np.argsort(self._values)
356
- inverse_order = np.argsort(order)
357
- ranks = np.array(self.weights.values)[order].cumsum()[inverse_order]
542
+ values = np.asarray(self._values)
543
+ weights = np.asarray(self.weights.values, dtype=float)
544
+ order = np.argsort(values, kind="mergesort")
545
+ sorted_values = values[order]
546
+ sorted_weights = weights[order]
547
+ cum_w = np.cumsum(sorted_weights)
548
+ # Max rank semantics: every tied group gets the cumulative
549
+ # weight at the *end* of the group, so ties share one rank.
550
+ # searchsorted(side='right') on the sorted values finds the
551
+ # index just past each tied block in sort order.
552
+ group_end = np.searchsorted(sorted_values, sorted_values, side="right") - 1
553
+ sorted_ranks = cum_w[group_end]
554
+ # Invert the sort to put ranks back into the caller's order.
555
+ inverse_order = np.argsort(order, kind="mergesort")
556
+ ranks = sorted_ranks[inverse_order]
358
557
  if pct:
359
- ranks /= weights_sum
558
+ ranks = ranks / weights_sum
360
559
  ranks = np.where(ranks > 1.0, 1.0, ranks)
361
560
  return MicroSeries(ranks, index=self.index, weights=self.weights)
362
561
 
@@ -2,6 +2,7 @@ import warnings
2
2
 
3
3
  import numpy as np
4
4
  import pandas as pd
5
+ import pytest
5
6
 
6
7
  import microdf as mdf
7
8
  from microdf.microdataframe import MicroDataFrame
@@ -445,6 +446,43 @@ def test_mean_no_warning() -> None:
445
446
  assert len(user_warnings) == 0
446
447
 
447
448
 
449
+ def test_sum_with_non_default_index() -> None:
450
+ """Weighted sum must not silently return 0 with a non-default index.
451
+
452
+ Regression test for the bug where ``set_weights`` stored the weights Series
453
+ with a default ``RangeIndex`` regardless of ``self.index``. Element-wise
454
+ ops like ``self.multiply(self.weights)`` then aligned on label, producing
455
+ all-NaN and a silent ``0.0`` from ``.sum()`` while ``.mean()`` (which uses
456
+ a positional ndarray) stayed correct.
457
+ """
458
+ # MicroSeries with custom integer index.
459
+ s = mdf.MicroSeries([1, 2, 3], index=[100, 200, 300], weights=[10, 20, 30])
460
+ assert s.sum() == 140.0
461
+ assert s.weights.index.tolist() == [100, 200, 300]
462
+
463
+ # MicroDataFrame with custom integer index.
464
+ df = mdf.MicroDataFrame(
465
+ {"x": [1, 2, 3]}, index=[100, 200, 300], weights=[10, 20, 30]
466
+ )
467
+ assert df.x.sum() == 140.0
468
+ assert df.weights.index.tolist() == [100, 200, 300]
469
+
470
+ # MicroDataFrame with string index + set_weights via column name.
471
+ df2 = mdf.MicroDataFrame(
472
+ {"x": [1, 2, 3], "w": [10, 20, 30]},
473
+ index=["a", "b", "c"],
474
+ )
475
+ df2.set_weights("w")
476
+ assert df2.x.sum() == 140.0
477
+ assert df2.weights.index.tolist() == ["a", "b", "c"]
478
+
479
+ # Passing a Series with its own index should position-align, not
480
+ # label-align, so sum does not depend on accidental index alignment.
481
+ df3 = mdf.MicroDataFrame({"x": [1, 2, 3]}, index=[100, 200, 300])
482
+ df3.set_weights(pd.Series([10, 20, 30], index=[0, 1, 2]))
483
+ assert df3.x.sum() == 140.0
484
+
485
+
448
486
  def test_repr_no_warning() -> None:
449
487
  """Internal .values usage in __repr__ should NOT emit a warning."""
450
488
  ms = mdf.MicroSeries([1, 2, 3], weights=[4, 5, 6])
@@ -453,3 +491,327 @@ def test_repr_no_warning() -> None:
453
491
  _ = repr(ms)
454
492
  user_warnings = [x for x in w if issubclass(x.category, UserWarning)]
455
493
  assert len(user_warnings) == 0
494
+
495
+
496
+ def test_drop_inplace_aligns_weights() -> None:
497
+ """Regression: ``drop(inplace=True)`` must keep weights in sync.
498
+
499
+ Previously, ``weights_backup = self.weights.copy()`` was taken *before*
500
+ the drop, then reassigned back afterwards — so the weights vector
501
+ kept its original length and any subsequent weighted op either
502
+ raised a length-mismatch ValueError or silently returned 0.
503
+ """
504
+ # Row drop inplace.
505
+ df = mdf.MicroDataFrame({"x": [1, 2, 3, 4]}, weights=[10, 20, 30, 40])
506
+ df.drop(index=[0, 1], inplace=True)
507
+ assert len(df) == len(df.weights) == 2
508
+ assert df.x.sum() == 3 * 30 + 4 * 40 # 250
509
+
510
+ # Row drop non-inplace.
511
+ df = mdf.MicroDataFrame({"x": [1, 2, 3, 4]}, weights=[10, 20, 30, 40])
512
+ df2 = df.drop(index=[0, 1])
513
+ assert len(df2) == len(df2.weights) == 2
514
+ assert df2.x.sum() == 250
515
+ # Original untouched.
516
+ assert len(df) == 4
517
+ assert df.x.sum() == 1 * 10 + 2 * 20 + 3 * 30 + 4 * 40
518
+
519
+ # Column drop (weights length unchanged).
520
+ df = mdf.MicroDataFrame({"x": [1, 2, 3], "y": [4, 5, 6]}, weights=[10, 20, 30])
521
+ df.drop(columns=["y"], inplace=True)
522
+ assert list(df.columns) == ["x"]
523
+ assert df.x.sum() == 1 * 10 + 2 * 20 + 3 * 30
524
+
525
+ # String index row drop.
526
+ df = mdf.MicroDataFrame(
527
+ {"x": [1, 2, 3, 4]},
528
+ index=["a", "b", "c", "d"],
529
+ weights=[10, 20, 30, 40],
530
+ )
531
+ df.drop(index=["a", "b"], inplace=True)
532
+ assert df.x.sum() == 250
533
+
534
+ # labels= with default axis=0.
535
+ df = mdf.MicroDataFrame({"x": [1, 2, 3, 4]}, weights=[10, 20, 30, 40])
536
+ df.drop(labels=[0, 1], inplace=True)
537
+ assert df.x.sum() == 250
538
+
539
+
540
+ def test_merge_preserves_weights_per_surviving_row() -> None:
541
+ """Regression: merge must propagate weights onto the merged rows.
542
+
543
+ Previously the implementation passed ``self.weights`` straight to the
544
+ MicroDataFrame constructor, so any merge that changed row count (inner
545
+ filtering, left-with-missing, many-to-many, outer) raised ``ValueError:
546
+ Length of weights (N) does not match length of DataFrame (M)``.
547
+ """
548
+ # Inner join filters rows.
549
+ left = mdf.MicroDataFrame(
550
+ {"k": [1, 2, 3, 4], "v": [10, 20, 30, 40]}, weights=[1, 2, 3, 4]
551
+ )
552
+ right = pd.DataFrame({"k": [2, 4], "w": [20, 40]})
553
+ res = left.merge(right, on="k")
554
+ assert len(res) == 2
555
+ # k=2 carries weight 2; k=4 carries weight 4.
556
+ np.testing.assert_array_equal(sorted(res.weights.values), [2.0, 4.0])
557
+ assert res.v.sum() == 2 * 20 + 4 * 40
558
+
559
+ # Left join with missing from right.
560
+ left = mdf.MicroDataFrame(
561
+ {"k": [1, 2, 3, 4], "v": [10, 20, 30, 40]}, weights=[1, 2, 3, 4]
562
+ )
563
+ right = pd.DataFrame({"k": [2, 4], "w": [100, 200]})
564
+ res = left.merge(right, on="k", how="left")
565
+ assert len(res) == 4
566
+ assert res.v.sum() == 300 # 1*10 + 2*20 + 3*30 + 4*40
567
+
568
+ # Many-to-many duplicates left rows; the same weight should ride
569
+ # along on each duplicate.
570
+ left = mdf.MicroDataFrame({"k": [1, 2], "v": [10, 20]}, weights=[5, 7])
571
+ right = pd.DataFrame({"k": [1, 1, 2], "w": [100, 200, 300]})
572
+ res = left.merge(right, on="k")
573
+ assert len(res) == 3
574
+ # v=10 weighted 5 appears twice, v=20 weighted 7 appears once.
575
+ assert res.v.sum() == 10 * 5 + 10 * 5 + 20 * 7
576
+
577
+ # Outer join: right-only rows have no left weight. We default to 0
578
+ # so they don't silently poison downstream aggregations.
579
+ left = mdf.MicroDataFrame({"k": [1, 2], "v": [10, 20]}, weights=[1, 2])
580
+ right = pd.DataFrame({"k": [2, 3], "w": [20, 30]})
581
+ res = left.merge(right, on="k", how="outer")
582
+ # k=1 -> weight 1, k=2 -> weight 2, k=3 -> weight 0 (right-only).
583
+ assert sorted(res.weights.values) == [0.0, 1.0, 2.0]
584
+
585
+
586
+ def test_groupby_does_not_leak_tmp_weights_column() -> None:
587
+ """Regression: groupby used to mutate self by adding __tmp_weights.
588
+
589
+ Previously, ``MicroDataFrame.groupby`` set ``self["__tmp_weights"]`` and
590
+ never cleaned it up, so ``df.columns`` afterwards included the weight
591
+ column and any later ``df.sum()`` or iteration over columns picked it up as
592
+ data.
593
+ """
594
+ df = mdf.MicroDataFrame({"g": ["a", "a", "b"], "v": [1, 2, 3]}, weights=[1, 2, 3])
595
+ original_cols = list(df.columns)
596
+ _ = df.groupby("g").sum()
597
+ assert list(df.columns) == original_cols
598
+ assert "__tmp_weights" not in df.columns
599
+
600
+ # Groupby by a list of columns should also not leak.
601
+ df2 = mdf.MicroDataFrame(
602
+ {"g1": ["a", "a", "b"], "g2": [1, 1, 2], "v": [1, 2, 3]},
603
+ weights=[1, 2, 3],
604
+ )
605
+ orig2 = list(df2.columns)
606
+ _ = df2.groupby(["g1", "g2"]).v.sum()
607
+ assert list(df2.columns) == orig2
608
+
609
+ # Weighted aggregation is still correct after the fix.
610
+ result = df.groupby("g").v.sum()
611
+ assert result["a"] == 1 * 1 + 2 * 2
612
+ assert result["b"] == 3 * 3
613
+
614
+
615
+ def test_quantile_skips_zero_weight_rows() -> None:
616
+ """Regression: quantile(0) shouldn't pick a zero-weight element.
617
+
618
+ Previously, ``np.searchsorted(cumsum_norm, 0, side='left')`` returned 0
619
+ even when that first sorted element had zero weight, so ``MicroSeries([10,
620
+ 20, 30], weights=[0, 1, 1]).quantile(0)`` returned 10 instead of 20. The
621
+ fix drops zero-weight rows before computing the CDF.
622
+ """
623
+ s = mdf.MicroSeries([10, 20, 30], weights=[0, 1, 1])
624
+ assert s.quantile(0.0) == 20
625
+ assert s.quantile(0.5) == 20
626
+ assert s.quantile(1.0) == 30
627
+
628
+ # Internal plateau of zero weight.
629
+ s = mdf.MicroSeries([10, 20, 30, 40], weights=[1, 0, 1, 1])
630
+ assert s.quantile(0.0) == 10
631
+ # Post-filter values [10, 30, 40] with equal weights -> cum=[.33,.67,1].
632
+ # 0.4 -> smallest cum >= 0.4 is index 1 -> value 30.
633
+ assert s.quantile(0.4) == 30
634
+ # The zero-weight value (20) should never be selected.
635
+ for q in np.linspace(0, 1, 21):
636
+ assert s.quantile(q) != 20
637
+
638
+ # All zero weights -> NaN (defined behaviour).
639
+ s = mdf.MicroSeries([10, 20, 30], weights=[0, 0, 0])
640
+ assert np.isnan(s.quantile(0.5))
641
+
642
+
643
+ def test_top_x_pct_share_handles_ties_and_edges() -> None:
644
+ """Regression: top_x_pct_share double-counted threshold ties.
645
+
646
+ Old implementation: ``self[self >= threshold].sum() / self.sum()``.
647
+ With constant values every call returned 1.0 regardless of the
648
+ requested top percent; ``top_x_pct_share(0)`` returned the share of
649
+ the max bucket instead of 0.
650
+ """
651
+ # Constant values: the top p% should hold exactly p% of the total.
652
+ for p in [0.0, 0.01, 0.1, 0.5, 1.0]:
653
+ got = mdf.MicroSeries([5] * 10, weights=[1] * 10).top_x_pct_share(p)
654
+ assert np.isclose(got, p), f"top={p}, got {got}"
655
+
656
+ # Non-constant, equal weights.
657
+ s = mdf.MicroSeries(list(range(1, 11)), weights=[1] * 10)
658
+ # Sum 1..10 = 55. Top 10% = top 1 row = 10 -> 10/55.
659
+ assert np.isclose(s.top_x_pct_share(0.1), 10 / 55)
660
+ # Top 50% = rows 6..10 -> 40/55.
661
+ assert np.isclose(s.top_x_pct_share(0.5), 40 / 55)
662
+ # Top 0% = 0, top 100% = 1.
663
+ assert s.top_x_pct_share(0.0) == 0.0
664
+ assert s.top_x_pct_share(1.0) == 1.0
665
+
666
+ # Bottom share complements the top share.
667
+ assert np.isclose(s.bottom_x_pct_share(0.1), 1 - s.top_x_pct_share(0.9))
668
+
669
+ # Ties with unequal totals.
670
+ s_ties = mdf.MicroSeries([1, 1, 10, 10], weights=[1, 1, 1, 1])
671
+ # Top 50% = the two 10s -> 20/22.
672
+ assert np.isclose(s_ties.top_x_pct_share(0.5), 20 / 22)
673
+
674
+ # Downstream helpers still work.
675
+ assert np.isclose(s_ties.top_10_pct_share(), s_ties.top_x_pct_share(0.1))
676
+ assert np.isclose(s_ties.top_50_pct_share(), s_ties.top_x_pct_share(0.5))
677
+
678
+
679
+ def test_gini_negatives_option_applied() -> None:
680
+ """Regression: gini(negatives=...) was silently ignored.
681
+
682
+ Both branches of the old implementation sorted ``self`` directly rather
683
+ than the local ``x`` that was mutated by the ``negatives`` option, so
684
+ ``negatives='zero'`` and ``negatives='shift'`` did nothing.
685
+ """
686
+ s = mdf.MicroSeries([-5, 0, 10], weights=[1, 1, 1])
687
+
688
+ # Leaving negatives in place now warns.
689
+ with warnings.catch_warnings(record=True) as w:
690
+ warnings.simplefilter("always")
691
+ _ = s.gini()
692
+ user_warnings = [x for x in w if issubclass(x.category, UserWarning)]
693
+ assert len(user_warnings) == 1
694
+ assert "negative" in str(user_warnings[0].message).lower()
695
+
696
+ # 'zero' clamps negatives. Values become [0, 0, 10] with equal
697
+ # weights; closed-form gini = 2/3.
698
+ assert np.isclose(s.gini(negatives="zero"), 2 / 3)
699
+
700
+ # 'shift' adds |min|. Values become [0, 5, 15]; Gini in [0, 1].
701
+ shifted = s.gini(negatives="shift")
702
+ assert 0 <= shifted <= 1
703
+
704
+ # All-zero short-circuits to 0 instead of nan/RuntimeWarning.
705
+ assert mdf.MicroSeries([0, 0, 0], weights=[1, 2, 3]).gini() == 0.0
706
+
707
+ # Invalid negatives arg raises.
708
+ with pytest.raises(ValueError):
709
+ mdf.MicroSeries([1, 2, 3], weights=[1, 1, 1]).gini(negatives="bogus")
710
+
711
+
712
+ def test_std_var_are_weighted() -> None:
713
+ """Regression: std/var used to silently fall through to pandas.
714
+
715
+ The old implementation had no override, so a MicroSeries with very uneven
716
+ weights returned the unweighted 1.0. Now std and var treat the weights as
717
+ frequency counts, matching numpy on the replicated sample.
718
+ """
719
+ s = mdf.MicroSeries([1, 2, 3], weights=[100, 1, 1])
720
+ # Unweighted would be 1.0. Weighted std pulls toward the heavy row.
721
+ assert s.std() < 1.0
722
+ assert s.var() < 1.0
723
+
724
+ # Integer-replication equivalence.
725
+ s = mdf.MicroSeries([1, 2, 3], weights=[2, 3, 1])
726
+ rep = np.array([1, 1, 2, 2, 2, 3])
727
+ assert np.isclose(s.std(), np.std(rep, ddof=1))
728
+ assert np.isclose(s.var(), np.var(rep, ddof=1))
729
+ assert np.isclose(s.var(ddof=0), np.var(rep, ddof=0))
730
+
731
+ # NaN handling.
732
+ s = mdf.MicroSeries([1.0, np.nan, 3.0], weights=[2, 3, 1])
733
+ assert not np.isnan(s.std())
734
+ assert np.isnan(s.std(skipna=False))
735
+
736
+ # DataFrame dispatch: df.std() / df.var() now return weighted stats.
737
+ df = mdf.MicroDataFrame({"x": [1, 2, 3], "y": [10, 20, 30]}, weights=[2, 3, 1])
738
+ np.testing.assert_allclose(
739
+ df.std().values,
740
+ [
741
+ np.std(rep, ddof=1),
742
+ np.std(np.array([10, 10, 20, 20, 20, 30]), ddof=1),
743
+ ],
744
+ )
745
+
746
+
747
+ def test_cov_corr_warn_when_fallthrough() -> None:
748
+ """Regression: cov/corr silently returned unweighted pandas values.
749
+
750
+ They still fall through to pandas (a weighted impl is a separate issue) but
751
+ now emit a UserWarning so callers aren't misled.
752
+ """
753
+ s1 = mdf.MicroSeries([1, 2, 3], weights=[1, 1, 1])
754
+ s2 = mdf.MicroSeries([2, 4, 6], weights=[1, 1, 1])
755
+
756
+ with warnings.catch_warnings(record=True) as w:
757
+ warnings.simplefilter("always")
758
+ _ = s1.cov(s2)
759
+ msgs = [str(x.message) for x in w if issubclass(x.category, UserWarning)]
760
+ assert any("unweighted" in m.lower() for m in msgs)
761
+
762
+ with warnings.catch_warnings(record=True) as w:
763
+ warnings.simplefilter("always")
764
+ _ = s1.corr(s2)
765
+ msgs = [str(x.message) for x in w if issubclass(x.category, UserWarning)]
766
+ assert any("unweighted" in m.lower() for m in msgs)
767
+
768
+
769
+ def test_count_skips_nan_by_default() -> None:
770
+ """Regression: ``count()`` included NaN-row weight, contrary to pandas.
771
+
772
+ Pandas ``Series.count`` skips NaN; MicroSeries returned the full weight sum
773
+ regardless. The fix matches pandas semantics and adds a ``skipna`` kwarg so
774
+ callers can opt out.
775
+ """
776
+ s = mdf.MicroSeries([1.0, np.nan, 3.0], weights=[10, 20, 30])
777
+ assert s.count() == 40.0
778
+ assert s.count(skipna=True) == 40.0
779
+ assert s.count(skipna=False) == 60.0
780
+
781
+ # No NaN: skipna is a no-op.
782
+ assert mdf.MicroSeries([1, 2, 3], weights=[2, 3, 4]).count() == 9.0
783
+
784
+ # All NaN: count skips everything.
785
+ all_nan = mdf.MicroSeries([np.nan] * 3, weights=[1, 2, 3])
786
+ assert all_nan.count() == 0.0
787
+ assert all_nan.count(skipna=False) == 6.0
788
+
789
+
790
+ def test_rank_ties_share_bucket() -> None:
791
+ """Regression: rank used to assign ties to different ranks/buckets.
792
+
793
+ Previously ``rank`` returned the running cumulative weight in sort order,
794
+ so every row — tied or not — got a distinct value. As a result
795
+ ``MicroSeries([5]*5, weights=[1]*5).decile_rank()`` returned ``[2, 4, 6, 8,
796
+ 10]`` rather than all 10. With max-rank semantics, tied values share the
797
+ cumulative weight at the end of their tie group, so bucketing is stable
798
+ under ties.
799
+ """
800
+ # All tied: every element lands in the top decile.
801
+ s = mdf.MicroSeries([5] * 5, weights=[1] * 5)
802
+ np.testing.assert_array_equal(s.rank().values, [5, 5, 5, 5, 5])
803
+ np.testing.assert_array_equal(s.decile_rank().values, [10] * 5)
804
+ np.testing.assert_array_equal(s.quintile_rank().values, [5] * 5)
805
+
806
+ # Partial ties.
807
+ s = mdf.MicroSeries([1, 2, 2, 3], weights=[1, 1, 1, 1])
808
+ np.testing.assert_array_equal(s.rank().values, [1, 3, 3, 4])
809
+
810
+ # pct=True normalizes to (0, 1] and still shares ranks on ties.
811
+ s = mdf.MicroSeries([5] * 4, weights=[1] * 4)
812
+ np.testing.assert_allclose(s.rank(pct=True).values, [1.0, 1.0, 1.0, 1.0])
813
+
814
+ # Non-ties still match the old cumulative-weight behaviour, so the
815
+ # existing ``test_rank`` expectations hold.
816
+ s = mdf.MicroSeries([1, 2, 3], weights=[4, 5, 6])
817
+ np.testing.assert_array_equal(s.rank().values, [4, 9, 15])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microdf-python
3
- Version: 1.2.3
3
+ Version: 1.3.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
@@ -50,13 +50,12 @@ import microdf as mdf
50
50
  import pandas as pd
51
51
 
52
52
  # Create sample data with weights
53
- df = pd.DataFrame({
54
- 'income': [10_000, 20_000, 30_000, 40_000, 50_000],
55
- 'weights': [1, 2, 3, 2, 1]
56
- })
53
+ df = pd.DataFrame(
54
+ {"income": [10_000, 20_000, 30_000, 40_000, 50_000], "weights": [1, 2, 3, 2, 1]}
55
+ )
57
56
 
58
57
  # Create a MicroDataFrame
59
- mdf_df = mdf.MicroDataFrame(df, weights='weights')
58
+ mdf_df = mdf.MicroDataFrame(df, weights="weights")
60
59
 
61
60
  # All operations are weight-aware
62
61
  print(mdf_df.income.mean()) # Weighted mean
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "microdf-python"
7
- version = "1.2.3"
7
+ version = "1.3.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