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.
@@ -0,0 +1,883 @@
1
+ import copy
2
+ import logging
3
+ import warnings
4
+ from functools import wraps
5
+ from typing import Callable, List, Optional, Union
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ from microdf.microseries import MicroSeries, MicroSeriesGroupBy
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class _MicroLocIndexer:
16
+ """Custom loc indexer that returns MicroDataFrame with proper weights."""
17
+
18
+ def __init__(self, mdf: "MicroDataFrame"):
19
+ self._mdf = mdf
20
+ # Get the parent's loc indexer
21
+ self._parent_loc = pd.DataFrame.loc.fget(mdf)
22
+
23
+ def __getitem__(self, key):
24
+ # Use the parent DataFrame's loc indexer
25
+ result = self._parent_loc[key]
26
+
27
+ if isinstance(result, pd.DataFrame):
28
+ # Get the filtered weights based on the result's index
29
+ new_weights = self._mdf.weights.reindex(result.index)
30
+ return MicroDataFrame(result, weights=new_weights)
31
+ elif isinstance(result, pd.Series):
32
+ # Single row or column selected
33
+ if result.name in self._mdf.columns:
34
+ # Column was selected - return MicroSeries with all weights
35
+ return MicroSeries(result, weights=self._mdf.weights)
36
+ else:
37
+ # Row was selected - return as-is (scalar values for each col)
38
+ return result
39
+ else:
40
+ # Scalar value
41
+ return result
42
+
43
+ def __setitem__(self, key, value):
44
+ self._parent_loc[key] = value
45
+ self._mdf._link_all_weights()
46
+
47
+
48
+ class _MicroILocIndexer:
49
+ """Custom iloc indexer that returns MicroDataFrame with proper weights."""
50
+
51
+ def __init__(self, mdf: "MicroDataFrame"):
52
+ self._mdf = mdf
53
+ # Get the parent's iloc indexer
54
+ self._parent_iloc = pd.DataFrame.iloc.fget(mdf)
55
+
56
+ def __getitem__(self, key):
57
+ # Use the parent DataFrame's iloc indexer
58
+ result = self._parent_iloc[key]
59
+
60
+ if isinstance(result, pd.DataFrame):
61
+ # Get the filtered weights based on the result's index
62
+ new_weights = self._mdf.weights.iloc[
63
+ self._mdf.index.get_indexer(result.index)
64
+ ]
65
+ new_weights = pd.Series(new_weights.values, index=result.index)
66
+ return MicroDataFrame(result, weights=new_weights)
67
+ elif isinstance(result, pd.Series):
68
+ # Single row or column selected
69
+ if isinstance(key, tuple) and len(key) == 2:
70
+ # df.iloc[:, col_idx] - column selection
71
+ row_key = key[0]
72
+ if isinstance(row_key, slice) and row_key == slice(None):
73
+ # All rows selected for a column
74
+ return MicroSeries(result, weights=self._mdf.weights)
75
+ # Check if this is a column (result index matches mdf index)
76
+ if result.index.equals(self._mdf.index):
77
+ return MicroSeries(result, weights=self._mdf.weights)
78
+ # Row selection - return as-is
79
+ return result
80
+ else:
81
+ # Scalar value
82
+ return result
83
+
84
+ def __setitem__(self, key, value):
85
+ self._parent_iloc[key] = value
86
+ self._mdf._link_all_weights()
87
+
88
+
89
+ class MicroDataFrame(pd.DataFrame):
90
+ def __init__(self, *args, weights=None, **kwargs):
91
+ """A DataFrame-inheriting class for weighted microdata. Weights can be
92
+ provided at initialisation, or using set_weights or set_weight_col.
93
+
94
+ :param weights: Array of weights.
95
+ :type weights: np.array
96
+ """
97
+ super().__init__(*args, **kwargs)
98
+ self.weights = None
99
+ self.set_weights(weights)
100
+ self._link_all_weights()
101
+ self.override_df_functions()
102
+
103
+ @property
104
+ def loc(self) -> _MicroLocIndexer:
105
+ """Label-based indexer that preserves MicroDataFrame type and weights.
106
+
107
+ :return: Custom loc indexer for MicroDataFrame
108
+ """
109
+ return _MicroLocIndexer(self)
110
+
111
+ @property
112
+ def iloc(self) -> _MicroILocIndexer:
113
+ """Integer-based indexer that preserves MicroDataFrame type and
114
+ weights.
115
+
116
+ :return: Custom iloc indexer for MicroDataFrame
117
+ """
118
+ return _MicroILocIndexer(self)
119
+
120
+ def override_df_functions(self) -> None:
121
+ """Override DataFrame functions to work with weighted operations."""
122
+ for name in MicroSeries.FUNCTIONS:
123
+ if name in MicroSeries.SCALAR_FUNCTIONS:
124
+ setattr(self, name, self._create_scalar_function(name))
125
+ elif name in MicroSeries.VECTOR_FUNCTIONS:
126
+ setattr(self, name, self._create_vector_function(name))
127
+ elif name in MicroSeries.AGNOSTIC_FUNCTIONS:
128
+ setattr(self, name, self._create_agnostic_function(name))
129
+
130
+ def _create_scalar_function(self, name: str) -> Callable:
131
+ """Create a scalar function that returns a Series of results.
132
+
133
+ :param name: Name of the function to create
134
+ :return: Function that applies the operation to all columns
135
+ """
136
+
137
+ def fn(*args, **kwargs) -> pd.Series:
138
+ results = {}
139
+ for col in self.columns:
140
+ if pd.api.types.is_numeric_dtype(self[col]):
141
+ try:
142
+ results[col] = getattr(self[col], name)(
143
+ *args, **kwargs
144
+ )
145
+ except Exception:
146
+ # Skip columns that can't be aggregated
147
+ pass
148
+ return pd.Series(results)
149
+
150
+ return fn
151
+
152
+ def _create_vector_function(self, name: str) -> Callable:
153
+ """Create a vector function that returns a DataFrame of results.
154
+
155
+ :param name: Name of the function to create
156
+ :return: Function that applies the operation to all columns
157
+ """
158
+
159
+ def fn(*args, **kwargs) -> pd.DataFrame:
160
+ results = []
161
+ columns = []
162
+ for col in self.columns:
163
+ if pd.api.types.is_numeric_dtype(self[col]):
164
+ try:
165
+ result = getattr(self[col], name)(*args, **kwargs)
166
+ results.append(result)
167
+ columns.append(col)
168
+ except Exception:
169
+ # Skip columns that can't be aggregated
170
+ pass
171
+
172
+ if results:
173
+ df = pd.DataFrame(results)
174
+ df.index = columns
175
+ return df
176
+ else:
177
+ return pd.DataFrame()
178
+
179
+ return fn
180
+
181
+ def _create_agnostic_function(self, name: str) -> Callable:
182
+ """Create a function that can be either scalar or vector based on
183
+ input.
184
+
185
+ :param name: Name of the function to create
186
+ :return: Function that applies the operation to all columns
187
+ """
188
+
189
+ def fn(*args, **kwargs) -> Union[pd.Series, pd.DataFrame]:
190
+ # Check if first argument is array-like
191
+ is_array = len(args) > 0 and hasattr(args[0], "__len__")
192
+
193
+ if is_array:
194
+ # Use vector function behavior
195
+ results = []
196
+ columns = []
197
+ for col in self.columns:
198
+ if pd.api.types.is_numeric_dtype(self[col]):
199
+ try:
200
+ result = getattr(self[col], name)(*args, **kwargs)
201
+ results.append(result)
202
+ columns.append(col)
203
+ except Exception:
204
+ # Skip columns that can't be aggregated
205
+ pass
206
+
207
+ if results:
208
+ df = pd.DataFrame(results)
209
+ df.index = columns
210
+ return df
211
+ else:
212
+ return pd.DataFrame()
213
+ else:
214
+ # Use scalar function behavior
215
+ results = {}
216
+ for col in self.columns:
217
+ if pd.api.types.is_numeric_dtype(self[col]):
218
+ try:
219
+ results[col] = getattr(self[col], name)(
220
+ *args, **kwargs
221
+ )
222
+ except Exception:
223
+ # Skip columns that can't be aggregated
224
+ pass
225
+ return pd.Series(results)
226
+
227
+ return fn
228
+
229
+ def get_args_as_micro_series(*kwarg_names: tuple) -> Callable:
230
+ """Decorator for auto-parsing column names into MicroSeries objects. If
231
+ given, kwarg_names limits arguments checked to keyword arguments
232
+ specified.
233
+
234
+ :param arg_names: argument names to restrict to.
235
+ :type arg_names: str
236
+ """
237
+
238
+ def arg_series_decorator(fn) -> Callable:
239
+ @wraps(fn)
240
+ def series_function(
241
+ self, *args, **kwargs
242
+ ) -> Union[pd.Series, pd.DataFrame]:
243
+ new_args = []
244
+ new_kwargs = {}
245
+ if len(kwarg_names) == 0:
246
+ for value in args:
247
+ if isinstance(value, str):
248
+ if value not in self.columns:
249
+ raise Exception("Column not found")
250
+ new_args += [self[value]]
251
+ else:
252
+ new_args += [value]
253
+ for name, value in kwargs.items():
254
+ if isinstance(value, str) and (
255
+ len(kwarg_names) == 0 or name in kwarg_names
256
+ ):
257
+ if value not in self.columns:
258
+ raise Exception("Column not found")
259
+ new_kwargs[name] = self[value]
260
+ else:
261
+ new_kwargs[name] = value
262
+ return fn(self, *new_args, **new_kwargs)
263
+
264
+ return series_function
265
+
266
+ return arg_series_decorator
267
+
268
+ def __setitem__(self, *args, **kwargs) -> None:
269
+ super().__setitem__(*args, **kwargs)
270
+ self._link_all_weights()
271
+
272
+ def _link_weights(self, column) -> None:
273
+ # self[column] = ... triggers __setitem__, which forces pd.Series
274
+ # this workaround avoids that
275
+ self[column].__class__ = MicroSeries
276
+ self[column].set_weights(self.weights)
277
+
278
+ def _link_all_weights(self) -> None:
279
+ if self.weights is None:
280
+ if len(self) > 0:
281
+ self.set_weights(np.ones((len(self))))
282
+ for column in self.columns:
283
+ if column != self.weights_col:
284
+ self._link_weights(column)
285
+
286
+ def set_weights(
287
+ self,
288
+ weights: Union[np.ndarray, str],
289
+ preserve_old: Optional[bool] = False,
290
+ ) -> None:
291
+ """Sets the weights for the MicroDataFrame. If a string is received, it
292
+ will be assumed to be the column name of the weight column.
293
+
294
+ :param weights: Array of weights.
295
+ :param preserve_old: If True, keeps the old weights as a column when
296
+ new weights are provided.
297
+ :type weights: np.array
298
+ """
299
+ if preserve_old and self.weights_col is not None:
300
+ self["old_" + self.weights_col] = self.weights
301
+
302
+ if isinstance(weights, str):
303
+ self.weights_col = weights
304
+ self.weights = pd.Series(self[weights], dtype=float)
305
+ self._link_all_weights()
306
+ elif weights is not None:
307
+ if len(weights) != len(self):
308
+ raise ValueError(
309
+ f"Length of weights ({len(weights)}) does not match "
310
+ f"length of DataFrame ({len(self)})."
311
+ )
312
+ self.weights_col = None
313
+ with warnings.catch_warnings():
314
+ warnings.filterwarnings("ignore", category=UserWarning)
315
+ self.weights = pd.Series(weights, dtype=float)
316
+ self._link_all_weights()
317
+
318
+ def set_weight_col(
319
+ self, column: str, preserve_old: Optional[bool] = False
320
+ ) -> None:
321
+ """Sets the weights for the MicroDataFrame by specifying the name of
322
+ the weight column.
323
+
324
+ .. deprecated:: 1.0.2
325
+ Use :meth:`set_weights` with a string argument instead.
326
+ This method will be removed in a future version.
327
+
328
+ :param column: Name of the column to use as weights.
329
+ :param preserve_old: If True, keeps the old weights as a column when
330
+ new weights are provided.
331
+ :type column: str
332
+ """
333
+ import warnings
334
+
335
+ warnings.warn(
336
+ "set_weight_col is deprecated and will be removed in a "
337
+ "future version. Use set_weights(column_name) instead.",
338
+ DeprecationWarning,
339
+ stacklevel=2,
340
+ )
341
+
342
+ if preserve_old and self.weights_col is not None:
343
+ self["old_" + self.weights_col] = self.weights
344
+
345
+ self.weights = np.array(self[column])
346
+ self.weights_col = column
347
+ self._link_all_weights()
348
+
349
+ def nullify_weights(self) -> None:
350
+ """Set all weights to 1, effectively making the DataFrame unweighted.
351
+
352
+ This is useful for comparing weighted and unweighted statistics or when
353
+ you want to temporarily ignore weights.
354
+ """
355
+ self.weights = np.ones(len(self))
356
+ self._link_all_weights()
357
+
358
+ def __getitem__(
359
+ self, key: Union[str, List]
360
+ ) -> Union[pd.Series, pd.DataFrame]:
361
+ # Let pandas handle the initial slicing
362
+ result = super().__getitem__(key)
363
+
364
+ # If the result is a DataFrame, re-synchronize the weights
365
+ if isinstance(result, pd.DataFrame):
366
+ new_weights = self.weights.reindex(result.index)
367
+ return MicroDataFrame(result, weights=new_weights)
368
+
369
+ # Otherwise, the result is a Series or a scalar, so just return it
370
+ return result
371
+
372
+ def catch_series_relapse(self) -> None:
373
+ for col in self.columns:
374
+ if self[col].__class__ == pd.Series:
375
+ self._link_weights(col)
376
+
377
+ def __setattr__(self, key, value) -> None:
378
+ super().__setattr__(key, value)
379
+ self.catch_series_relapse()
380
+
381
+ def reset_index(
382
+ self,
383
+ level: Optional[int] = None,
384
+ drop: Optional[bool] = False,
385
+ inplace: Optional[bool] = False,
386
+ col_level: Optional[int] = 0,
387
+ col_fill: Optional[str] = "",
388
+ allow_duplicates: Optional[bool] = None,
389
+ names: Optional[List[str]] = None,
390
+ ) -> Union["MicroDataFrame", None]:
391
+ """Reset the index of the MicroDataFrame.
392
+
393
+ This method supports all parameters of pandas DataFrame.reset_index(),
394
+ including the 'inplace' parameter.
395
+
396
+ :param level: Only remove the given levels from the index. Removes all
397
+ levels by default.
398
+ :param drop: Do not try to insert index into dataframe columns. This
399
+ resets the index to the default integer index.
400
+ :param inplace: Modify the DataFrame in place (do not create a new
401
+ object).
402
+ :param col_level: If the columns have multiple levels, determines which
403
+ level the labels are inserted into.
404
+ :param col_fill: If the columns have multiple levels, determines how
405
+ the other levels are named.
406
+ :param allow_duplicates: Allow duplicate column labels to be created.
407
+ :param names: Using the given string, rename the DataFrame column which
408
+ contains the index data.
409
+ :return: MicroDataFrame with reset index or None if inplace=True.
410
+ """
411
+ if inplace:
412
+ weights_backup = self.weights.copy()
413
+ # Perform in-place reset on the parent DataFrame
414
+ super().reset_index(
415
+ level=level,
416
+ drop=drop,
417
+ inplace=True,
418
+ col_level=col_level,
419
+ col_fill=col_fill,
420
+ allow_duplicates=allow_duplicates,
421
+ names=names,
422
+ )
423
+ self.weights = weights_backup
424
+ self._link_all_weights()
425
+ return None
426
+ else:
427
+ res = super().reset_index(
428
+ level=level,
429
+ drop=drop,
430
+ inplace=False,
431
+ col_level=col_level,
432
+ col_fill=col_fill,
433
+ allow_duplicates=allow_duplicates,
434
+ names=names,
435
+ )
436
+ return MicroDataFrame(res, weights=self.weights)
437
+
438
+ def copy(self, deep: Optional[bool] = True) -> "MicroDataFrame":
439
+ res = super().copy(deep)
440
+ # This changes the original columns to Series. Undo it:
441
+ for col in self.columns:
442
+ self[col] = MicroSeries(self[col])
443
+ res = MicroDataFrame(res, weights=self.weights.copy(deep))
444
+ return res
445
+
446
+ def drop(
447
+ self,
448
+ labels=None,
449
+ axis=0,
450
+ index=None,
451
+ columns=None,
452
+ level=None,
453
+ inplace=False,
454
+ errors="raise",
455
+ ):
456
+ """Drop specified labels from rows or columns.
457
+
458
+ This method supports all parameters of pandas DataFrame.drop(),
459
+ including the 'inplace' parameter.
460
+
461
+ :param labels: Index or column labels to drop.
462
+ :param axis: Whether to drop labels from the index (0 or 'index') or
463
+ columns (1 or 'columns').
464
+ :param index: Alternative to specifying axis (labels, axis=0 is
465
+ equivalent to index=labels).
466
+ :param columns: Alternative to specifying axis (labels, axis=1 is
467
+ equivalent to columns=labels).
468
+ :param level: For MultiIndex, level from which the labels will be
469
+ removed.
470
+ :param inplace: If False, return a copy. Otherwise, do operation
471
+ inplace and return None.
472
+ :param errors: If 'ignore', suppress error and only existing labels are
473
+ dropped.
474
+ :return: MicroDataFrame or None if inplace=True.
475
+ """
476
+ if inplace:
477
+ weights_backup = self.weights.copy()
478
+ # Perform in-place drop on the parent DataFrame
479
+ super().drop(
480
+ labels=labels,
481
+ axis=axis,
482
+ index=index,
483
+ columns=columns,
484
+ level=level,
485
+ inplace=True,
486
+ errors=errors,
487
+ )
488
+ self.weights = weights_backup
489
+ self._link_all_weights()
490
+ return None
491
+ else:
492
+ res = super().drop(
493
+ labels=labels,
494
+ axis=axis,
495
+ index=index,
496
+ columns=columns,
497
+ level=level,
498
+ inplace=False,
499
+ errors=errors,
500
+ )
501
+ return MicroDataFrame(res, weights=self.weights)
502
+
503
+ def merge(
504
+ self,
505
+ right,
506
+ how="inner",
507
+ on=None,
508
+ left_on=None,
509
+ right_on=None,
510
+ left_index=False,
511
+ right_index=False,
512
+ sort=False,
513
+ suffixes=("_x", "_y"),
514
+ copy=True,
515
+ indicator=False,
516
+ validate=None,
517
+ ):
518
+ """Merge DataFrame or named Series objects with a database-style join.
519
+
520
+ This method overrides pandas DataFrame.merge() to return a
521
+ MicroDataFrame.
522
+
523
+ :param right: Object to merge with.
524
+ :param how: Type of merge to be performed.
525
+ :param on: Column or index level names to join on.
526
+ :param left_on: Column or index level names to join on in the left
527
+ DataFrame.
528
+ :param right_on: Column or index level names to join on in the right
529
+ DataFrame.
530
+ :param left_index: Use the index from the left DataFrame as the join
531
+ key(s).
532
+ :param right_index: Use the index from the right DataFrame as the join
533
+ key(s).
534
+ :param sort: Sort the join keys lexicographically in the result
535
+ DataFrame.
536
+ :param suffixes: A length-2 sequence where each element is optionally a
537
+ string indicating the suffix to add to overlapping column names.
538
+ :param copy: If False, avoid copy if possible.
539
+ :param indicator: If True, adds a column to output DataFrame called
540
+ "_merge".
541
+ :param validate: If specified, checks if merge is of specified type.
542
+ :return: MicroDataFrame with merged data.
543
+ """
544
+ res = super().merge(
545
+ right,
546
+ how=how,
547
+ on=on,
548
+ left_on=left_on,
549
+ right_on=right_on,
550
+ left_index=left_index,
551
+ right_index=right_index,
552
+ sort=sort,
553
+ suffixes=suffixes,
554
+ copy=copy,
555
+ indicator=indicator,
556
+ validate=validate,
557
+ )
558
+
559
+ # For inner join, both dataframes must have the same weights on
560
+ # matching rows. For now, we'll use the left dataframe's weights.
561
+ # This is a simplification and may need more sophisticated handling
562
+ return MicroDataFrame(res, weights=self.weights)
563
+
564
+ def __getattr__(self, name):
565
+ """Allow accessing columns as attributes (e.g., df.column_name).
566
+
567
+ This enables more intuitive column access while preserving MicroSeries
568
+ functionality when accessing columns.
569
+
570
+ :param name: Attribute name to access
571
+ :return: MicroSeries if the attribute is a column, otherwise delegates
572
+ to parent
573
+ """
574
+ if name in self.columns:
575
+ return self[name]
576
+ return super().__getattr__(name)
577
+
578
+ def equals(self, other: "MicroDataFrame") -> bool:
579
+ equal_values = super().equals(other)
580
+ equal_weights = self.weights.equals(other.weights)
581
+ return equal_values and equal_weights
582
+
583
+ @get_args_as_micro_series()
584
+ def groupby(
585
+ self, by: Union[str, List], *args, **kwargs
586
+ ) -> "MicroDataFrameGroupBy":
587
+ """Returns a GroupBy object with MicroSeriesGroupBy objects for each
588
+ column.
589
+
590
+ :param by: column to group by
591
+ :type by: Union[str, List]
592
+
593
+ return: DataFrameGroupBy object with columns using weights
594
+ rtype: DataFrameGroupBy
595
+ """
596
+ self["__tmp_weights"] = self.weights
597
+ gb = super().groupby(by, *args, **kwargs)
598
+ weights = copy.deepcopy(gb["__tmp_weights"])
599
+ for col in self.columns: # df.groupby(...)[col]s use weights
600
+ res = gb[col]
601
+ res.__class__ = MicroSeriesGroupBy
602
+ res._init()
603
+ res.weights = weights
604
+ setattr(gb, col, res)
605
+ gb.__class__ = MicroDataFrameGroupBy
606
+ gb._init(by)
607
+ return gb
608
+
609
+ @get_args_as_micro_series()
610
+ def poverty_rate(self, income: str, threshold: str) -> float:
611
+ """Calculate poverty rate, i.e., the population share with income below
612
+ their poverty threshold.
613
+
614
+ :param income: Column indicating income.
615
+ :type income: str
616
+ :param threshold: Column indicating threshold.
617
+ :type threshold: str
618
+ :return: Poverty rate between zero and one.
619
+ :rtype: float
620
+ """
621
+ pov = income < threshold
622
+ return pov.sum() / pov.count()
623
+
624
+ @get_args_as_micro_series()
625
+ def deep_poverty_rate(self, income: str, threshold: str) -> float:
626
+ """Calculate deep poverty rate, i.e., the population share with income
627
+ below half their poverty threshold.
628
+
629
+ :param income: Column indicating income.
630
+ :type income: str
631
+ :param threshold: Column indicating threshold.
632
+ :type threshold: str
633
+ :return: Deep poverty rate between zero and one.
634
+ :rtype: float
635
+ """
636
+ pov = income < (threshold / 2)
637
+ return pov.sum() / pov.count()
638
+
639
+ @get_args_as_micro_series()
640
+ def poverty_gap(self, income: str, threshold: str) -> float:
641
+ """Calculate poverty gap, i.e., the total gap between income and
642
+ poverty thresholds for all people in poverty.
643
+
644
+ :param income: Column indicating income.
645
+ :type income: str
646
+ :param threshold: Column indicating threshold.
647
+ :type threshold: str
648
+ :return: Poverty gap.
649
+ :rtype: float
650
+ """
651
+ gaps = (threshold - income)[threshold > income]
652
+ return gaps.sum()
653
+
654
+ @get_args_as_micro_series()
655
+ def deep_poverty_gap(self, income: str, threshold: str) -> float:
656
+ """Calculate deep poverty gap, i.e., the total gap between income and
657
+ half of poverty thresholds for all people in deep poverty.
658
+
659
+ :param income: Column indicating income.
660
+ :type income: str
661
+ :param threshold: Column indicating threshold.
662
+ :type threshold: str
663
+ :return: Deep poverty gap.
664
+ :rtype: float
665
+ """
666
+ deep_threshold = threshold / 2
667
+ gaps = (deep_threshold - income)[deep_threshold > income]
668
+ return gaps.sum()
669
+
670
+ @get_args_as_micro_series()
671
+ def squared_poverty_gap(self, income: str, threshold: str) -> float:
672
+ """Calculate squared poverty gap, i.e., the total squared gap between
673
+ income and poverty thresholds for all people in poverty. Also known as
674
+ the poverty severity index.
675
+
676
+ :param income: Column indicating income.
677
+ :type income: str
678
+ :param threshold: Column indicating threshold.
679
+ :type threshold: str
680
+ :return: Squared poverty gap.
681
+ :rtype: float
682
+ """
683
+ gaps = (threshold - income)[threshold > income]
684
+ squared_gaps = gaps**2
685
+ return squared_gaps.sum()
686
+
687
+ @get_args_as_micro_series()
688
+ def poverty_count(
689
+ self,
690
+ income: Union[MicroSeries, str],
691
+ threshold: Union[MicroSeries, str],
692
+ ) -> int:
693
+ """Calculates the number of entities with income below a poverty
694
+ threshold.
695
+
696
+ :param income: income array or column name
697
+ :type income: Union[MicroSeries, str]
698
+
699
+ :param threshold: threshold array or column name
700
+ :type threshold: Union[MicroSeries, str]
701
+
702
+ return: number of entities in poverty
703
+ rtype: int
704
+ """
705
+ in_poverty = income < threshold
706
+ return in_poverty.sum()
707
+
708
+ def astype(
709
+ self,
710
+ dtype,
711
+ copy: Optional[bool] = True,
712
+ errors: Optional[str] = "raise",
713
+ ) -> "MicroDataFrame":
714
+ """Convert MicroDataFrame to specified data type while preserving
715
+ weights.
716
+
717
+ :param dtype: Data type to convert to. Can be numpy dtype, Python type,
718
+ or dict.
719
+ :param copy: Whether to make a copy of the data (default True).
720
+ :param errors: How to handle conversion errors (default "raise").
721
+ :return: New MicroDataFrame with converted data types and preserved
722
+ weights.
723
+ """
724
+ converted_df = super().astype(dtype, copy=copy, errors=errors)
725
+ return MicroDataFrame(
726
+ converted_df, weights=self.weights.copy() if copy else self.weights
727
+ )
728
+
729
+ def __repr__(self) -> str:
730
+ df = pd.DataFrame(self)
731
+ df["weight"] = self.weights
732
+ return df[[df.columns[-1]] + list(df.columns[:-1])].__repr__()
733
+
734
+
735
+ class MicroDataFrameGroupBy(pd.core.groupby.generic.DataFrameGroupBy):
736
+ def _init(self, by: Union[str, List]):
737
+ self._by = by
738
+ self.columns = list(self.obj.columns)
739
+ if isinstance(by, list):
740
+ for column in by:
741
+ self.columns.remove(column)
742
+ elif isinstance(by, str):
743
+ self.columns.remove(by)
744
+ self.columns.remove("__tmp_weights")
745
+ # Filter to only numeric columns
746
+ self.numeric_columns = [
747
+ col
748
+ for col in self.columns
749
+ if pd.api.types.is_numeric_dtype(self.obj[col])
750
+ ]
751
+ # Store reference to weights groupby for column selection
752
+ self._weights_groupby = copy.deepcopy(
753
+ super().__getitem__("__tmp_weights")
754
+ )
755
+ for fn_name in MicroSeries.SCALAR_FUNCTIONS:
756
+
757
+ def get_fn(name):
758
+ def fn(*args, **kwargs):
759
+ results = {}
760
+ for col in self.numeric_columns:
761
+ try:
762
+ results[col] = getattr(getattr(self, col), name)(
763
+ *args, **kwargs
764
+ )
765
+ except Exception:
766
+ # Skip columns that can't be aggregated
767
+ pass
768
+ # Return plain DataFrame - aggregated results don't have
769
+ # per-row weights (weights were already applied)
770
+ return pd.DataFrame(results) if results else pd.DataFrame()
771
+
772
+ return fn
773
+
774
+ setattr(self, fn_name, get_fn(fn_name))
775
+ for fn_name in MicroSeries.VECTOR_FUNCTIONS:
776
+
777
+ def get_fn(name) -> Callable:
778
+ def fn(*args, **kwargs) -> Union[pd.Series, pd.DataFrame]:
779
+ results = {}
780
+ for col in self.numeric_columns:
781
+ try:
782
+ results[col] = getattr(getattr(self, col), name)(
783
+ *args, **kwargs
784
+ )
785
+ except Exception:
786
+ # Skip columns that can't be aggregated
787
+ pass
788
+ # Return plain DataFrame - aggregated results don't have
789
+ # per-row weights (weights were already applied)
790
+ return pd.DataFrame(results) if results else pd.DataFrame()
791
+
792
+ return fn
793
+
794
+ setattr(self, fn_name, get_fn(fn_name))
795
+
796
+ def __getitem__(
797
+ self, key: Union[str, List]
798
+ ) -> Union["MicroSeriesGroupBy", "MicroDataFrameGroupBy"]:
799
+ """Select columns from the groupby object while preserving weights.
800
+
801
+ This ensures that operations like groupby(col)["y"].sum() or
802
+ groupby(col)[["y"]].sum() use weighted aggregation.
803
+
804
+ :param key: Column name or list of column names
805
+ :return: MicroSeriesGroupBy for single column, MicroDataFrameGroupBy
806
+ for multiple columns
807
+ """
808
+ if isinstance(key, str):
809
+ # Single column - return MicroSeriesGroupBy
810
+ result = super().__getitem__(key)
811
+ result.__class__ = MicroSeriesGroupBy
812
+ result._init()
813
+ result.weights = self._weights_groupby
814
+ return result
815
+ else:
816
+ # Multiple columns - return a new MicroDataFrameGroupBy
817
+ # with only the selected columns
818
+ result = super().__getitem__(key)
819
+ result.__class__ = MicroDataFrameGroupBy
820
+ # Re-initialize with the subset of columns
821
+ result._by = self._by
822
+ result.columns = list(key) if hasattr(key, "__iter__") else [key]
823
+ result.numeric_columns = [
824
+ col
825
+ for col in result.columns
826
+ if pd.api.types.is_numeric_dtype(result.obj[col])
827
+ ]
828
+ result._weights_groupby = self._weights_groupby
829
+ # Set up the column attributes as MicroSeriesGroupBy
830
+ for col in result.columns:
831
+ col_gb = super().__getitem__(col)
832
+ col_gb.__class__ = MicroSeriesGroupBy
833
+ col_gb._init()
834
+ col_gb.weights = self._weights_groupby
835
+ setattr(result, col, col_gb)
836
+ # Set up the scalar and vector functions
837
+ for fn_name in MicroSeries.SCALAR_FUNCTIONS:
838
+
839
+ def get_scalar_fn(name, res):
840
+ def fn(*args, **kwargs):
841
+ results = {}
842
+ for col in res.numeric_columns:
843
+ try:
844
+ results[col] = getattr(
845
+ getattr(res, col), name
846
+ )(*args, **kwargs)
847
+ except Exception:
848
+ pass
849
+ # Return plain DataFrame - aggregated results don't
850
+ # have per-row weights (weights were already applied)
851
+ return (
852
+ pd.DataFrame(results)
853
+ if results
854
+ else pd.DataFrame()
855
+ )
856
+
857
+ return fn
858
+
859
+ setattr(result, fn_name, get_scalar_fn(fn_name, result))
860
+ for fn_name in MicroSeries.VECTOR_FUNCTIONS:
861
+
862
+ def get_vector_fn(name, res):
863
+ def fn(*args, **kwargs):
864
+ results = {}
865
+ for col in res.numeric_columns:
866
+ try:
867
+ results[col] = getattr(
868
+ getattr(res, col), name
869
+ )(*args, **kwargs)
870
+ except Exception:
871
+ pass
872
+ # Return plain DataFrame - aggregated results don't
873
+ # have per-row weights (weights were already applied)
874
+ return (
875
+ pd.DataFrame(results)
876
+ if results
877
+ else pd.DataFrame()
878
+ )
879
+
880
+ return fn
881
+
882
+ setattr(result, fn_name, get_vector_fn(fn_name, result))
883
+ return result