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.
- {microdf_python-1.2.3/microdf_python.egg-info → microdf_python-1.3.0}/PKG-INFO +1 -1
- {microdf_python-1.2.3 → microdf_python-1.3.0}/microdf/microdataframe.py +89 -21
- {microdf_python-1.2.3 → microdf_python-1.3.0}/microdf/microseries.py +246 -48
- {microdf_python-1.2.3 → microdf_python-1.3.0}/microdf/tests/test_microseries_dataframe.py +366 -0
- {microdf_python-1.2.3 → microdf_python-1.3.0/microdf_python.egg-info}/PKG-INFO +1 -1
- {microdf_python-1.2.3 → microdf_python-1.3.0}/pyproject.toml +1 -1
- {microdf_python-1.2.3 → microdf_python-1.3.0}/LICENSE +0 -0
- {microdf_python-1.2.3 → microdf_python-1.3.0}/README.md +0 -0
- {microdf_python-1.2.3 → microdf_python-1.3.0}/microdf/__init__.py +0 -0
- {microdf_python-1.2.3 → microdf_python-1.3.0}/microdf/tests/conftest.py +0 -0
- {microdf_python-1.2.3 → microdf_python-1.3.0}/microdf/tests/test_pandas3_compatibility.py +0 -0
- {microdf_python-1.2.3 → microdf_python-1.3.0}/microdf_python.egg-info/SOURCES.txt +0 -0
- {microdf_python-1.2.3 → microdf_python-1.3.0}/microdf_python.egg-info/dependency_links.txt +0 -0
- {microdf_python-1.2.3 → microdf_python-1.3.0}/microdf_python.egg-info/requires.txt +0 -0
- {microdf_python-1.2.3 → microdf_python-1.3.0}/microdf_python.egg-info/top_level.txt +0 -0
- {microdf_python-1.2.3 → microdf_python-1.3.0}/setup.cfg +0 -0
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
420
|
-
#
|
|
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 =
|
|
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
|
-
|
|
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
|
-
#
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
567
|
-
#
|
|
568
|
-
#
|
|
569
|
-
|
|
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
|
-
|
|
602
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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]
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
# The
|
|
251
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
|
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])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|