microdf-python 1.2.3__py3-none-any.whl → 1.3.2__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 +100 -27
- microdf/microseries.py +249 -50
- microdf/tests/test_microseries_dataframe.py +362 -0
- {microdf_python-1.2.3.dist-info → microdf_python-1.3.2.dist-info}/METADATA +5 -6
- microdf_python-1.3.2.dist-info/RECORD +11 -0
- {microdf_python-1.2.3.dist-info → microdf_python-1.3.2.dist-info}/WHEEL +1 -1
- microdf_python-1.2.3.dist-info/RECORD +0 -11
- {microdf_python-1.2.3.dist-info → microdf_python-1.3.2.dist-info}/licenses/LICENSE +0 -0
- {microdf_python-1.2.3.dist-info → microdf_python-1.3.2.dist-info}/top_level.txt +0 -0
microdf/microdataframe.py
CHANGED
|
@@ -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.
|
|
100
|
-
|
|
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.
|
|
235
|
-
|
|
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.
|
|
296
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
420
|
-
#
|
|
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 =
|
|
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
|
-
|
|
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
|
-
#
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
567
|
-
#
|
|
568
|
-
#
|
|
569
|
-
|
|
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
|
-
|
|
602
|
-
|
|
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
|
|
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()
|
microdf/microseries.py
CHANGED
|
@@ -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.
|
|
15
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
235
|
-
|
|
236
|
-
x
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
# The
|
|
251
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
|
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
|
+
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
|
-
|
|
55
|
-
|
|
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=
|
|
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
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
microdf/__init__.py,sha256=ldRvhE8t4cD7qlxQ5yLndMnWEUYpDw4m-jCDV_psXkY,319
|
|
2
|
+
microdf/microdataframe.py,sha256=X6OB4Cy8_ENyr1tzplYMdbtVY33zvHmrHX1KUpAEiLE,37284
|
|
3
|
+
microdf/microseries.py,sha256=9ScONBPMH03KtkIWxWYqcwsZ7c761FhWCr8DSnJkZQU,33394
|
|
4
|
+
microdf/tests/conftest.py,sha256=u-EMyX1-u_nM-YO0RJYCzYHQDXxUI2WQE6GkyJlErqg,150
|
|
5
|
+
microdf/tests/test_microseries_dataframe.py,sha256=r9S9z2RFgnFZFMsaT72N-zClT6jEL8YxQ_MUPAr3xi8,30969
|
|
6
|
+
microdf/tests/test_pandas3_compatibility.py,sha256=A34Ni_WQ303sSNv-sqv5CGAQp54zj-ZSGAPEBHZslNI,8573
|
|
7
|
+
microdf_python-1.3.2.dist-info/licenses/LICENSE,sha256=uPs-ASYnzlldpf2z8jeRgQFeEH3FLhSuX0rw0OKWoDU,1067
|
|
8
|
+
microdf_python-1.3.2.dist-info/METADATA,sha256=Mq_ElGrPKz7E3wjCXGBWnuwt40SrDoLSh9hTg8tqSto,2307
|
|
9
|
+
microdf_python-1.3.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
microdf_python-1.3.2.dist-info/top_level.txt,sha256=T2WFPTygQQMdS3GF8YpZ12DKfMGrspbZ3r7z-e3KfiM,8
|
|
11
|
+
microdf_python-1.3.2.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
microdf/__init__.py,sha256=ldRvhE8t4cD7qlxQ5yLndMnWEUYpDw4m-jCDV_psXkY,319
|
|
2
|
-
microdf/microdataframe.py,sha256=kjID2Zn_8zEp5IG7zc81B9M9QoAb2smMhczL3m8-n1k,33506
|
|
3
|
-
microdf/microseries.py,sha256=fl5GHRRmeehxA-701bbTbz93T7a_BOpjEDmS2BvXSfQ,24775
|
|
4
|
-
microdf/tests/conftest.py,sha256=u-EMyX1-u_nM-YO0RJYCzYHQDXxUI2WQE6GkyJlErqg,150
|
|
5
|
-
microdf/tests/test_microseries_dataframe.py,sha256=WhwxyoFNPQLCvtsIPfAic0Zk71jWedZ6MT0ZaYBPEXk,16186
|
|
6
|
-
microdf/tests/test_pandas3_compatibility.py,sha256=A34Ni_WQ303sSNv-sqv5CGAQp54zj-ZSGAPEBHZslNI,8573
|
|
7
|
-
microdf_python-1.2.3.dist-info/licenses/LICENSE,sha256=uPs-ASYnzlldpf2z8jeRgQFeEH3FLhSuX0rw0OKWoDU,1067
|
|
8
|
-
microdf_python-1.2.3.dist-info/METADATA,sha256=bbTmyHLxi1ThumndS_GMYNlb93WH-PjFSS7h6KJ5j2A,2311
|
|
9
|
-
microdf_python-1.2.3.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
10
|
-
microdf_python-1.2.3.dist-info/top_level.txt,sha256=T2WFPTygQQMdS3GF8YpZ12DKfMGrspbZ3r7z-e3KfiM,8
|
|
11
|
-
microdf_python-1.2.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|