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