microdf-python 1.1.1__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/microseries.py ADDED
@@ -0,0 +1,643 @@
1
+ import logging
2
+ from functools import wraps
3
+ from typing import Callable, List, Optional, Union
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class MicroSeries(pd.Series):
12
+ def __init__(self, *args, weights: np.array = None, **kwargs):
13
+ """A Series-inheriting class for weighted microdata. Weights can be
14
+ provided at initialisation, or using set_weights.
15
+
16
+ :param weights: Array of weights.
17
+ :type weights: np.array
18
+ """
19
+ super().__init__(*args, **kwargs)
20
+ self.set_weights(weights)
21
+
22
+ def weighted_function(fn: Callable) -> Callable:
23
+ @wraps(fn)
24
+ def safe_fn(*args, **kwargs):
25
+ try:
26
+ return fn(*args, **kwargs)
27
+ except ZeroDivisionError:
28
+ return np.NaN
29
+
30
+ return safe_fn
31
+
32
+ @weighted_function
33
+ def scalar_function(fn: Callable) -> Callable:
34
+ fn._rtype = float
35
+ return fn
36
+
37
+ @weighted_function
38
+ def vector_function(fn: Callable) -> Callable:
39
+ fn._rtype = pd.Series
40
+ return fn
41
+
42
+ def set_weights(
43
+ self, weights: np.array, preserve_old: Optional[bool] = False
44
+ ) -> None:
45
+ """Sets the weight values.
46
+
47
+ :param weights: Array of weights.
48
+ :param preserve_old: If True, keeps the old weights as a column when
49
+ new weights are provided.
50
+ :type weights: np.array.
51
+ """
52
+ if weights is None:
53
+ if len(self) > 0:
54
+ self.weights = pd.Series(
55
+ np.ones_like(self.values), dtype=float
56
+ )
57
+ else:
58
+ if len(weights) != len(self):
59
+ raise ValueError(
60
+ f"Length of weights ({len(weights)}) does not match "
61
+ f"length of DataFrame ({len(self)})."
62
+ )
63
+
64
+ if preserve_old and self.weights is not None:
65
+ self["old_weights"] = self.weights
66
+
67
+ self.weights = pd.Series(weights, dtype=float)
68
+
69
+ def nullify_weights(self) -> None:
70
+ """Set all weights to 1, effectively making the Series unweighted.
71
+
72
+ This is useful for comparing weighted and unweighted statistics or when
73
+ you want to temporarily ignore weights.
74
+ """
75
+ self.weights = pd.Series(np.ones(len(self)), dtype=float)
76
+
77
+ @vector_function
78
+ def weight(self) -> pd.Series:
79
+ """Calculates the weighted value of the MicroSeries.
80
+
81
+ :returns: A Series multiplying the MicroSeries by its weight.
82
+ :rtype: pd.Series
83
+ """
84
+ return self.multiply(self.weights)
85
+
86
+ @scalar_function
87
+ def sum(self) -> float:
88
+ """Calculates the weighted sum of the MicroSeries.
89
+
90
+ :returns: The weighted sum.
91
+ :rtype: float
92
+ """
93
+ return self.multiply(self.weights).sum()
94
+
95
+ @scalar_function
96
+ def count(self) -> float:
97
+ """Calculates the weighted count of the MicroSeries.
98
+
99
+ :returns: The weighted count.
100
+ """
101
+ return self.weights.sum()
102
+
103
+ @scalar_function
104
+ def mean(self) -> float:
105
+ """Calculates the weighted mean of the MicroSeries.
106
+
107
+ :returns: The weighted mean.
108
+ :rtype: float
109
+ """
110
+ return np.average(self.values, weights=self.weights)
111
+
112
+ def quantile(self, q: np.array) -> pd.Series:
113
+ """Calculates weighted quantiles of the MicroSeries.
114
+
115
+ Uses the inverse CDF method: the q-th quantile is the smallest
116
+ value where the cumulative weight proportion >= q. This matches
117
+ the default behavior of R's survey::svyquantile.
118
+
119
+ :param q: Quantile(s) to calculate, must be in [0, 1].
120
+ :type q: float or np.array
121
+
122
+ :return: Weighted quantile value(s).
123
+ :rtype: float or pd.Series
124
+ """
125
+ values = np.array(self.values)
126
+ quantiles = np.atleast_1d(q)
127
+ sample_weight = np.array(self.weights)
128
+ assert np.all(quantiles >= 0) and np.all(
129
+ quantiles <= 1
130
+ ), "quantiles should be in [0, 1]"
131
+ sorter = np.argsort(values)
132
+ values = values[sorter]
133
+ sample_weight = sample_weight[sorter]
134
+ cumsum = np.cumsum(sample_weight)
135
+ cumsum_normalized = cumsum / cumsum[-1]
136
+ result = np.array(
137
+ [
138
+ values[
139
+ min(
140
+ np.searchsorted(cumsum_normalized, qi), len(values) - 1
141
+ )
142
+ ]
143
+ for qi in quantiles
144
+ ]
145
+ )
146
+ if np.array(q).shape == ():
147
+ return result[0]
148
+ return pd.Series(result, index=quantiles)
149
+
150
+ @scalar_function
151
+ def median(self) -> float:
152
+ """Calculates the weighted median of the MicroSeries.
153
+
154
+ :returns: The weighted median of a DataFrame's column.
155
+ :rtype: float
156
+ """
157
+ return self.quantile(0.5)
158
+
159
+ @scalar_function
160
+ def gini(self, negatives: Optional[str] = None) -> float:
161
+ """Calculates Gini index.
162
+
163
+ :param negatives: An optional string indicating how to treat negative
164
+ values of x:
165
+ 'zero' replaces negative values with zeroes.
166
+ 'shift' subtracts the minimum value from all values of x,
167
+ when this minimum is negative. That is, it adds the absolute
168
+ minimum value.
169
+ Defaults to None, which leaves negative values as they are.
170
+ :type q: str
171
+ :returns: Gini index.
172
+ :rtype: float
173
+ """
174
+ x = np.array(self).astype("float")
175
+ if negatives == "zero":
176
+ x[x < 0] = 0
177
+ if negatives == "shift" and np.amin(x) < 0:
178
+ x -= np.amin(x)
179
+ if (self.weights != np.ones(len(self))).any(): # Varying weights.
180
+ sorted_indices = np.argsort(self)
181
+ sorted_x = np.array(self[sorted_indices])
182
+ sorted_w = np.array(self.weights[sorted_indices])
183
+ cumw = np.cumsum(sorted_w)
184
+ cumxw = np.cumsum(sorted_x * sorted_w)
185
+ return np.sum(cumxw[1:] * cumw[:-1] - cumxw[:-1] * cumw[1:]) / (
186
+ cumxw[-1] * cumw[-1]
187
+ )
188
+ else:
189
+ sorted_x = np.sort(self)
190
+ n = len(x)
191
+ cumxw = np.cumsum(sorted_x)
192
+ # The above formula, with all weights equal to 1 simplifies to:
193
+ return (n + 1 - 2 * np.sum(cumxw) / cumxw[-1]) / n
194
+
195
+ @scalar_function
196
+ def top_x_pct_share(self, top_x_pct: float) -> float:
197
+ """Calculates top x% share.
198
+
199
+ :param top_x_pct: Decimal between 0 and 1 of the top %, e.g. 0.1,
200
+ 0.001.
201
+ :type top_x_pct: float
202
+ :returns: The weighted share held by the top x%.
203
+ :rtype: float
204
+ """
205
+ threshold = self.quantile(1 - top_x_pct)
206
+ top_x_pct_sum = self[self >= threshold].sum()
207
+ total_sum = self.sum()
208
+ return top_x_pct_sum / total_sum
209
+
210
+ @scalar_function
211
+ def bottom_x_pct_share(self, bottom_x_pct: float) -> float:
212
+ """Calculates bottom x% share.
213
+
214
+ :param bottom_x_pct: Decimal between 0 and 1 of the top %, e.g. 0.1,
215
+ 0.001.
216
+ :type bottom_x_pct: float
217
+ :returns: The weighted share held by the bottom x%.
218
+ :rtype: float
219
+ """
220
+ return 1 - self.top_x_pct_share(1 - bottom_x_pct)
221
+
222
+ @scalar_function
223
+ def bottom_50_pct_share(self) -> float:
224
+ """Calculates bottom 50% share.
225
+
226
+ :returns: The weighted share held by the bottom 50%.
227
+ :rtype: float
228
+ """
229
+ return self.bottom_x_pct_share(0.5)
230
+
231
+ @scalar_function
232
+ def top_50_pct_share(self) -> float:
233
+ """Calculates top 50% share.
234
+
235
+ :returns: The weighted share held by the top 50%.
236
+ :rtype: float
237
+ """
238
+ return self.top_x_pct_share(0.5)
239
+
240
+ @scalar_function
241
+ def top_10_pct_share(self) -> float:
242
+ """Calculates top 10% share.
243
+
244
+ :returns: The weighted share held by the top 10%.
245
+ :rtype: float
246
+ """
247
+ return self.top_x_pct_share(0.1)
248
+
249
+ @scalar_function
250
+ def top_1_pct_share(self) -> float:
251
+ """Calculates top 1% share.
252
+
253
+ :returns: The weighted share held by the top 50%.
254
+ :rtype: float
255
+ """
256
+ return self.top_x_pct_share(0.01)
257
+
258
+ @scalar_function
259
+ def top_0_1_pct_share(self) -> float:
260
+ """Calculates top 0.1% share.
261
+
262
+ :returns: The weighted share held by the top 0.1%.
263
+ :rtype: float
264
+ """
265
+ return self.top_x_pct_share(0.001)
266
+
267
+ @scalar_function
268
+ def t10_b50(self) -> float:
269
+ """Calculates ratio between the top 10% and bottom 50% shares.
270
+
271
+ :returns: The weighted share held by the top 10% divided by the
272
+ weighted share held by the bottom 50%.
273
+ """
274
+ t10 = self.top_10_pct_share()
275
+ b50 = self.bottom_50_pct_share()
276
+ return t10 / b50
277
+
278
+ @vector_function
279
+ def cumsum(self) -> pd.Series:
280
+ logger.warning(
281
+ "cumsum() returns cumulative sums of weighted values as a regular "
282
+ "pandas Series. The original weights have already been applied "
283
+ "and cannot be reused with the cumulative results."
284
+ )
285
+ return pd.Series(self * self.weights).cumsum()
286
+
287
+ @vector_function
288
+ def rank(self, pct: Optional[bool] = False) -> pd.Series:
289
+ weights_sum = self.weights.values.sum()
290
+ if weights_sum == 0:
291
+ raise ZeroDivisionError(
292
+ "Cannot calculate rank with zero total weight. "
293
+ "All weights in the MicroSeries are zero, which would result "
294
+ "in division by zero."
295
+ )
296
+
297
+ order = np.argsort(self.values)
298
+ inverse_order = np.argsort(order)
299
+ ranks = np.array(self.weights.values)[order].cumsum()[inverse_order]
300
+ if pct:
301
+ ranks /= weights_sum
302
+ ranks = np.where(ranks > 1.0, 1.0, ranks)
303
+ return MicroSeries(ranks, index=self.index, weights=self.weights)
304
+
305
+ @vector_function
306
+ def decile_rank(self, negatives_in_zero: Optional[bool] = False):
307
+ """Calculate decile ranks (1-10) with optional zero decile for
308
+ negatives.
309
+
310
+ :param negatives_in_zero: If True, negative values are assigned to
311
+ decile 0. If False (default), all values are ranked 1-10.
312
+ :type negatives_in_zero: bool
313
+ :returns: MicroSeries with decile ranks
314
+ :rtype: MicroSeries
315
+ """
316
+ if negatives_in_zero:
317
+ negative_mask = self < 0
318
+ if negative_mask.any():
319
+ non_negative_values = self[~negative_mask]
320
+ if len(non_negative_values) > 0:
321
+ non_neg_ranks = non_negative_values.rank(pct=True)
322
+ deciles = np.minimum(np.ceil(non_neg_ranks * 10), 10)
323
+ else:
324
+ deciles = np.array([])
325
+
326
+ result = np.zeros(len(self))
327
+ result[negative_mask] = 0
328
+ if len(deciles) > 0:
329
+ result[~negative_mask] = deciles
330
+
331
+ return MicroSeries(result, weights=self.weights)
332
+
333
+ # Default behavior: rank all values 1-10
334
+ return MicroSeries(
335
+ np.minimum(np.ceil(self.rank(pct=True) * 10), 10),
336
+ weights=self.weights,
337
+ )
338
+
339
+ @vector_function
340
+ def quintile_rank(self) -> "MicroSeries":
341
+ return MicroSeries(
342
+ np.minimum(np.ceil(self.rank(pct=True) * 5), 5),
343
+ weights=self.weights,
344
+ )
345
+
346
+ @vector_function
347
+ def quartile_rank(self) -> "MicroSeries":
348
+ return MicroSeries(
349
+ np.minimum(np.ceil(self.rank(pct=True) * 4), 4),
350
+ weights=self.weights,
351
+ )
352
+
353
+ @vector_function
354
+ def percentile_rank(self) -> "MicroSeries":
355
+ return MicroSeries(
356
+ np.minimum(np.ceil(self.rank(pct=True) * 100), 100),
357
+ weights=self.weights,
358
+ )
359
+
360
+ def groupby(self, *args, **kwargs) -> "MicroSeriesGroupBy":
361
+ gb = super().groupby(*args, **kwargs)
362
+ gb.__class__ = MicroSeriesGroupBy
363
+ gb._init()
364
+ gb.weights = pd.Series(self.weights).groupby(*args, **kwargs)
365
+ return gb
366
+
367
+ def copy(self, deep: Optional[bool] = True):
368
+ res = super().copy(deep)
369
+ res = MicroSeries(res, weights=self.weights.copy(deep))
370
+ return res
371
+
372
+ def clip(
373
+ self,
374
+ lower: Optional[float] = None,
375
+ upper: Optional[float] = None,
376
+ axis: Optional[int] = None,
377
+ inplace: Optional[bool] = False,
378
+ *args,
379
+ **kwargs,
380
+ ) -> "MicroSeries":
381
+ res = super().clip(
382
+ lower=lower,
383
+ upper=upper,
384
+ axis=axis,
385
+ inplace=inplace,
386
+ *args,
387
+ **kwargs,
388
+ )
389
+ if not inplace:
390
+ return MicroSeries(res, weights=self.weights)
391
+ return self
392
+
393
+ def round(
394
+ self, decimals: Optional[int] = 0, *args, **kwargs
395
+ ) -> "MicroSeries":
396
+ res = super().round(decimals=decimals, *args, **kwargs)
397
+ return MicroSeries(res, weights=self.weights)
398
+
399
+ def equals(self, other: "MicroSeries") -> bool:
400
+ equal_values = super().equals(other)
401
+ equal_weights = self.weights.equals(other.weights)
402
+ return equal_values and equal_weights
403
+
404
+ def __getitem__(
405
+ self, key: Union[str, int, slice, List, np.ndarray]
406
+ ) -> Union["MicroSeries", pd.Series]:
407
+ result = super().__getitem__(key)
408
+ if isinstance(result, pd.Series):
409
+ weights = self.weights.__getitem__(key)
410
+ return MicroSeries(result, weights=weights)
411
+ return result
412
+
413
+ def __getattr__(self, name: str) -> "MicroSeries":
414
+ return MicroSeries(super().__getattr__(name), weights=self.weights)
415
+
416
+ # operators
417
+
418
+ def __add__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
419
+ return MicroSeries(super().__add__(other), weights=self.weights)
420
+
421
+ def __sub__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
422
+ return MicroSeries(super().__sub__(other), weights=self.weights)
423
+
424
+ def __mul__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
425
+ return MicroSeries(super().__mul__(other), weights=self.weights)
426
+
427
+ def __floordiv__(
428
+ self, other: Union[int, float, pd.Series]
429
+ ) -> "MicroSeries":
430
+ return MicroSeries(super().__floordiv__(other), weights=self.weights)
431
+
432
+ def __truediv__(
433
+ self, other: Union[int, float, pd.Series]
434
+ ) -> "MicroSeries":
435
+ return MicroSeries(super().__truediv__(other), weights=self.weights)
436
+
437
+ def __mod__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
438
+ return MicroSeries(super().__mod__(other), weights=self.weights)
439
+
440
+ def __pow__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
441
+ return MicroSeries(super().__pow__(other), weights=self.weights)
442
+
443
+ def __xor__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
444
+ return MicroSeries(super().__xor__(other), weights=self.weights)
445
+
446
+ def __and__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
447
+ return MicroSeries(super().__and__(other), weights=self.weights)
448
+
449
+ def __or__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
450
+ return MicroSeries(super().__or__(other), weights=self.weights)
451
+
452
+ def __invert__(self) -> "MicroSeries":
453
+ return MicroSeries(super().__invert__(), weights=self.weights)
454
+
455
+ def __radd__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
456
+ return MicroSeries(super().__radd__(other), weights=self.weights)
457
+
458
+ def __rsub__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
459
+ return MicroSeries(super().__rsub__(other), weights=self.weights)
460
+
461
+ def __rmul__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
462
+ return MicroSeries(super().__rmul__(other), weights=self.weights)
463
+
464
+ def __rfloordiv__(
465
+ self, other: Union[int, float, pd.Series]
466
+ ) -> "MicroSeries":
467
+ return MicroSeries(super().__rfloordiv__(other), weights=self.weights)
468
+
469
+ def __rtruediv__(
470
+ self, other: Union[int, float, pd.Series]
471
+ ) -> "MicroSeries":
472
+ return MicroSeries(super().__rtruediv__(other), weights=self.weights)
473
+
474
+ def __rmod__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
475
+ return MicroSeries(super().__rmod__(other), weights=self.weights)
476
+
477
+ def __rpow__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
478
+ return MicroSeries(super().__rpow__(other), weights=self.weights)
479
+
480
+ def __rand__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
481
+ return MicroSeries(super().__rand__(other), weights=self.weights)
482
+
483
+ def __ror__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
484
+ return MicroSeries(super().__ror__(other), weights=self.weights)
485
+
486
+ def __rxor__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
487
+ return MicroSeries(super().__rxor__(other), weights=self.weights)
488
+
489
+ def sqrt(self) -> "MicroSeries":
490
+ sqrt_values = np.sqrt(self.values)
491
+ return MicroSeries(sqrt_values, index=self.index, weights=self.weights)
492
+
493
+ # comparators
494
+
495
+ def __lt__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
496
+ return MicroSeries(super().__lt__(other), weights=self.weights)
497
+
498
+ def __le__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
499
+ return MicroSeries(super().__le__(other), weights=self.weights)
500
+
501
+ def __eq__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
502
+ return MicroSeries(super().__eq__(other), weights=self.weights)
503
+
504
+ def __ne__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
505
+ return MicroSeries(super().__ne__(other), weights=self.weights)
506
+
507
+ def __ge__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
508
+ return MicroSeries(super().__ge__(other), weights=self.weights)
509
+
510
+ def __gt__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
511
+ return MicroSeries(super().__gt__(other), weights=self.weights)
512
+
513
+ # assignment operators
514
+
515
+ def __iadd__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
516
+ return MicroSeries(super().__iadd__(other), weights=self.weights)
517
+
518
+ def __isub__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
519
+ return MicroSeries(super().__isub__(other), weights=self.weights)
520
+
521
+ def __imul__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
522
+ return MicroSeries(super().__imul__(other), weights=self.weights)
523
+
524
+ def __ifloordiv__(
525
+ self, other: Union[int, float, pd.Series]
526
+ ) -> "MicroSeries":
527
+ return MicroSeries(super().__ifloordiv__(other), weights=self.weights)
528
+
529
+ def __idiv__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
530
+ return MicroSeries(super().__idiv__(other), weights=self.weights)
531
+
532
+ def __itruediv__(
533
+ self, other: Union[int, float, pd.Series]
534
+ ) -> "MicroSeries":
535
+ return MicroSeries(super().__itruediv__(other), weights=self.weights)
536
+
537
+ def __imod__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
538
+ return MicroSeries(super().__imod__(other), weights=self.weights)
539
+
540
+ def __ipow__(self, other: Union[int, float, pd.Series]) -> "MicroSeries":
541
+ return MicroSeries(super().__ipow__(other), weights=self.weights)
542
+
543
+ # other
544
+
545
+ def __neg__(self) -> "MicroSeries":
546
+ return MicroSeries(super().__neg__(), weights=self.weights)
547
+
548
+ def __pos__(self) -> "MicroSeries":
549
+ return MicroSeries(super().__pos__(), weights=self.weights)
550
+
551
+ def astype(
552
+ self,
553
+ dtype,
554
+ copy: Optional[bool] = True,
555
+ errors: Optional[str] = "raise",
556
+ ) -> "MicroSeries":
557
+ """Convert MicroSeries to specified data type while preserving weights.
558
+
559
+ :param dtype: Data type to convert to. Can be numpy dtype or Python
560
+ type.
561
+ :param copy: Whether to make a copy of the data (default True).
562
+ :param errors: How to handle conversion errors (default "raise").
563
+ :return: New MicroSeries with converted data type and preserved
564
+ weights.
565
+ """
566
+ converted_series = super().astype(dtype, copy=copy, errors=errors)
567
+ return MicroSeries(
568
+ converted_series,
569
+ weights=self.weights.copy() if copy else self.weights,
570
+ )
571
+
572
+ def __repr__(self) -> str:
573
+ return pd.DataFrame(
574
+ dict(value=self.values, weight=self.weights.values)
575
+ ).__repr__()
576
+
577
+
578
+ MicroSeries.SCALAR_FUNCTIONS = [
579
+ fn
580
+ for fn in dir(MicroSeries)
581
+ if "_rtype" in dir(getattr(MicroSeries, fn))
582
+ and getattr(getattr(MicroSeries, fn), "_rtype") == float
583
+ ]
584
+ MicroSeries.VECTOR_FUNCTIONS = [
585
+ fn
586
+ for fn in dir(MicroSeries)
587
+ if "_rtype" in dir(getattr(MicroSeries, fn))
588
+ and getattr(getattr(MicroSeries, fn), "_rtype") == pd.Series
589
+ ]
590
+ MicroSeries.AGNOSTIC_FUNCTIONS = ["quantile"]
591
+ MicroSeries.FUNCTIONS = sum(
592
+ [
593
+ MicroSeries.SCALAR_FUNCTIONS,
594
+ MicroSeries.VECTOR_FUNCTIONS,
595
+ MicroSeries.AGNOSTIC_FUNCTIONS,
596
+ ],
597
+ [],
598
+ )
599
+
600
+
601
+ class MicroSeriesGroupBy(pd.core.groupby.generic.SeriesGroupBy):
602
+ def _init(self):
603
+ def _weighted_agg(name) -> Callable:
604
+ def via_micro_series(row, *args, **kwargs):
605
+ return getattr(MicroSeries(row.a, weights=row.w), name)(
606
+ *args, **kwargs
607
+ )
608
+
609
+ fn = getattr(MicroSeries, name)
610
+
611
+ @wraps(fn)
612
+ def _weighted_agg_fn(
613
+ *args, **kwargs
614
+ ) -> Union[pd.Series, pd.DataFrame]:
615
+ arrays = self.apply(np.array)
616
+ weights = self.weights.apply(np.array)
617
+ df = pd.DataFrame(dict(a=arrays, w=weights))
618
+ is_array = len(args) > 0 and hasattr(args[0], "__len__")
619
+ if (
620
+ name in MicroSeries.SCALAR_FUNCTIONS
621
+ or name in MicroSeries.AGNOSTIC_FUNCTIONS
622
+ and not is_array
623
+ ):
624
+ result = df.agg(
625
+ lambda row: via_micro_series(row, *args, **kwargs),
626
+ axis=1,
627
+ )
628
+ elif (
629
+ name in MicroSeries.VECTOR_FUNCTIONS
630
+ or name in MicroSeries.AGNOSTIC_FUNCTIONS
631
+ and is_array
632
+ ):
633
+ result = df.apply(
634
+ lambda row: via_micro_series(row, *args, **kwargs),
635
+ axis=1,
636
+ )
637
+ return result.stack()
638
+ return result
639
+
640
+ return _weighted_agg_fn
641
+
642
+ for fn_name in MicroSeries.FUNCTIONS:
643
+ setattr(self, fn_name, _weighted_agg(fn_name))
@@ -0,0 +1,9 @@
1
+ import os
2
+
3
+ import pytest
4
+
5
+
6
+ @pytest.fixture(scope="session")
7
+ def tests_path() -> str:
8
+ """"""
9
+ return os.path.abspath(os.path.dirname(__file__))